Merge branch 'jskim-node' into ycshin-node
- 충돌 해결: TableListComponent.tsx - 양쪽 import 유지 (useTabId + getAdaptiveLabelColor) - buttonColor에 다크모드 대응 함수 적용 Made-with: Cursor
This commit is contained in:
commit
51e1abee2b
|
|
@ -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,82 @@
|
||||||
|
# WACE ERP 파이프라인 공통 룰 (모든 에이전트 필수 준수)
|
||||||
|
|
||||||
|
## 1. 화면 유형 구분 (절대 규칙!)
|
||||||
|
|
||||||
|
이 시스템은 **관리자 메뉴**와 **사용자 메뉴**가 완전히 다른 방식으로 동작한다.
|
||||||
|
기능 구현 시 반드시 어느 유형인지 먼저 판단하라.
|
||||||
|
|
||||||
|
### 관리자 메뉴 (Admin)
|
||||||
|
- **구현 방식**: React 코드 기반 페이지 (`.tsx` 파일)
|
||||||
|
- **경로**: `frontend/app/(main)/admin/{기능명}/page.tsx`
|
||||||
|
- **메뉴 등록**: `menu_info` 테이블에 INSERT 필수 (코드만 만들고 메뉴 등록 안 하면 미완성!)
|
||||||
|
- **대상**: 시스템 설정, 사용자 관리, 결재 관리, 코드 관리 등
|
||||||
|
- **특징**: 하드코딩된 UI, 관리자만 접근
|
||||||
|
|
||||||
|
### 사용자 메뉴 (User/Screen)
|
||||||
|
- **구현 방식**: 로우코드 기반 (DB에 JSON으로 화면 구성 저장)
|
||||||
|
- **데이터 저장**: `screen_layouts` 테이블에 JSON 형식 보관
|
||||||
|
- **화면 디자이너**: 스크린 디자이너로 드래그앤드롭 구성
|
||||||
|
- **V2 컴포넌트**: `frontend/lib/registry/components/v2-*` 디렉토리
|
||||||
|
- **대상**: 일반 업무 화면, BOM, 문서 관리 등
|
||||||
|
- **특징**: 코드 수정 없이 화면 구성 변경 가능
|
||||||
|
|
||||||
|
### 판단 기준
|
||||||
|
|
||||||
|
| 질문 | 관리자 메뉴 | 사용자 메뉴 |
|
||||||
|
|------|-------------|-------------|
|
||||||
|
| 누가 쓰나? | 시스템 관리자 | 일반 사용자 |
|
||||||
|
| 화면 구조 고정? | 고정 (코드) | 유동적 (JSON) |
|
||||||
|
| URL 패턴 | `/admin/*` | 스크린 디자이너 경유 |
|
||||||
|
| 메뉴 등록 | `menu_info` INSERT 필수 | 스크린 레이아웃 등록 |
|
||||||
|
|
||||||
|
## 2. 관리자 메뉴 등록 (코드 구현 후 필수!)
|
||||||
|
|
||||||
|
관리자 기능을 코드로 만들었으면 반드시 `menu_info`에 등록해야 한다.
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 예시: 결재 템플릿 관리 메뉴 등록
|
||||||
|
INSERT INTO menu_info (menu_id, menu_name, url, parent_id, menu_type, sort_order, is_active, company_code)
|
||||||
|
VALUES ('approvalTemplate', '결재 템플릿', '/admin/approvalTemplate', 'approval', 'ADMIN', 40, 'Y', '대상회사코드');
|
||||||
|
```
|
||||||
|
|
||||||
|
- 기존 메뉴 구조를 먼저 조회해서 parent_id, sort_order 등을 맞춰라
|
||||||
|
- 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 엔드포인트 구현 + 라우트 등록
|
||||||
|
- [ ] BE: company_code 필터링 적용
|
||||||
|
- [ ] FE: API 클라이언트 함수 작성 (lib/api/)
|
||||||
|
- [ ] FE: 화면 컴포넌트 구현
|
||||||
|
- [ ] **메뉴 등록**: 관리자 메뉴면 menu_info INSERT, 사용자 메뉴면 스크린 레이아웃 등록
|
||||||
|
- [ ] 빌드 통과: 백엔드 tsc + 프론트엔드 tsc
|
||||||
|
|
||||||
|
## 6. 절대 하지 말 것
|
||||||
|
|
||||||
|
1. 페이지 파일만 만들고 메뉴 등록 안 하기 (미완성!)
|
||||||
|
2. fetch() 직접 사용 (lib/api/ 클라이언트 필수)
|
||||||
|
3. company_code 필터링 빠뜨리기
|
||||||
|
4. 하드코딩 색상/URL/사용자ID 사용
|
||||||
|
5. Card 안에 Card 중첩 (중첩 박스 금지)
|
||||||
|
6. 백엔드 재실행하기 (nodemon이 자동 재시작)
|
||||||
|
|
@ -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,63 @@
|
||||||
|
---
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
# 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,50 @@
|
||||||
|
---
|
||||||
|
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
|
||||||
|
|
||||||
|
# Your Domain
|
||||||
|
- frontend/components/ (UI components)
|
||||||
|
- frontend/app/ (pages)
|
||||||
|
|
||||||
|
# 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"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
@ -1046,7 +1046,6 @@
|
||||||
"integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==",
|
"integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/code-frame": "^7.27.1",
|
"@babel/code-frame": "^7.27.1",
|
||||||
"@babel/generator": "^7.28.3",
|
"@babel/generator": "^7.28.3",
|
||||||
|
|
@ -2374,7 +2373,6 @@
|
||||||
"resolved": "https://registry.npmjs.org/@redis/client/-/client-1.6.1.tgz",
|
"resolved": "https://registry.npmjs.org/@redis/client/-/client-1.6.1.tgz",
|
||||||
"integrity": "sha512-/KCsg3xSlR+nCK8/8ZYSknYxvXHwubJrU82F3Lm1Fp6789VQ0/3RJKfsmRXjqfaTA++23CvC3hqmqe/2GEt6Kw==",
|
"integrity": "sha512-/KCsg3xSlR+nCK8/8ZYSknYxvXHwubJrU82F3Lm1Fp6789VQ0/3RJKfsmRXjqfaTA++23CvC3hqmqe/2GEt6Kw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"cluster-key-slot": "1.1.2",
|
"cluster-key-slot": "1.1.2",
|
||||||
"generic-pool": "3.9.0",
|
"generic-pool": "3.9.0",
|
||||||
|
|
@ -3487,7 +3485,6 @@
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.17.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.17.tgz",
|
||||||
"integrity": "sha512-gfehUI8N1z92kygssiuWvLiwcbOB3IRktR6hTDgJlXMYh5OvkPSRmgfoBUmfZt+vhwJtX7v1Yw4KvvAf7c5QKQ==",
|
"integrity": "sha512-gfehUI8N1z92kygssiuWvLiwcbOB3IRktR6hTDgJlXMYh5OvkPSRmgfoBUmfZt+vhwJtX7v1Yw4KvvAf7c5QKQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~6.21.0"
|
"undici-types": "~6.21.0"
|
||||||
}
|
}
|
||||||
|
|
@ -3724,7 +3721,6 @@
|
||||||
"integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==",
|
"integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "BSD-2-Clause",
|
"license": "BSD-2-Clause",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/scope-manager": "6.21.0",
|
"@typescript-eslint/scope-manager": "6.21.0",
|
||||||
"@typescript-eslint/types": "6.21.0",
|
"@typescript-eslint/types": "6.21.0",
|
||||||
|
|
@ -3942,7 +3938,6 @@
|
||||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"acorn": "bin/acorn"
|
"acorn": "bin/acorn"
|
||||||
},
|
},
|
||||||
|
|
@ -4468,7 +4463,6 @@
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"baseline-browser-mapping": "^2.8.3",
|
"baseline-browser-mapping": "^2.8.3",
|
||||||
"caniuse-lite": "^1.0.30001741",
|
"caniuse-lite": "^1.0.30001741",
|
||||||
|
|
@ -5679,7 +5673,6 @@
|
||||||
"deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
|
"deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/eslint-utils": "^4.2.0",
|
"@eslint-community/eslint-utils": "^4.2.0",
|
||||||
"@eslint-community/regexpp": "^4.6.1",
|
"@eslint-community/regexpp": "^4.6.1",
|
||||||
|
|
@ -5958,7 +5951,6 @@
|
||||||
"resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz",
|
"resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz",
|
||||||
"integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==",
|
"integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"accepts": "~1.3.8",
|
"accepts": "~1.3.8",
|
||||||
"array-flatten": "1.1.1",
|
"array-flatten": "1.1.1",
|
||||||
|
|
@ -7494,7 +7486,6 @@
|
||||||
"integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==",
|
"integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@jest/core": "^29.7.0",
|
"@jest/core": "^29.7.0",
|
||||||
"@jest/types": "^29.6.3",
|
"@jest/types": "^29.6.3",
|
||||||
|
|
@ -8464,6 +8455,7 @@
|
||||||
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
||||||
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
|
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"js-tokens": "^3.0.0 || ^4.0.0"
|
"js-tokens": "^3.0.0 || ^4.0.0"
|
||||||
},
|
},
|
||||||
|
|
@ -9351,7 +9343,6 @@
|
||||||
"resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz",
|
"resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz",
|
||||||
"integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==",
|
"integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"pg-connection-string": "^2.9.1",
|
"pg-connection-string": "^2.9.1",
|
||||||
"pg-pool": "^3.10.1",
|
"pg-pool": "^3.10.1",
|
||||||
|
|
@ -10207,6 +10198,7 @@
|
||||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
|
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
|
||||||
"integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
|
"integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"loose-envify": "^1.1.0"
|
"loose-envify": "^1.1.0"
|
||||||
}
|
}
|
||||||
|
|
@ -11014,7 +11006,6 @@
|
||||||
"integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==",
|
"integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@cspotcode/source-map-support": "^0.8.0",
|
"@cspotcode/source-map-support": "^0.8.0",
|
||||||
"@tsconfig/node10": "^1.0.7",
|
"@tsconfig/node10": "^1.0.7",
|
||||||
|
|
@ -11120,7 +11111,6 @@
|
||||||
"integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==",
|
"integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
"tsserver": "bin/tsserver"
|
"tsserver": "bin/tsserver"
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
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 : "알 수 없는 오류",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -5,6 +5,7 @@ import {
|
||||||
ApprovalRequestController,
|
ApprovalRequestController,
|
||||||
ApprovalLineController,
|
ApprovalLineController,
|
||||||
} from "../controllers/approvalController";
|
} from "../controllers/approvalController";
|
||||||
|
import { ApprovalProxyController } from "../controllers/approvalProxyController";
|
||||||
import { authenticateToken } from "../middleware/authMiddleware";
|
import { authenticateToken } from "../middleware/authMiddleware";
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
@ -30,9 +31,17 @@ router.get("/requests", ApprovalRequestController.getRequests);
|
||||||
router.get("/requests/:id", ApprovalRequestController.getRequest);
|
router.get("/requests/:id", ApprovalRequestController.getRequest);
|
||||||
router.post("/requests", ApprovalRequestController.createRequest);
|
router.post("/requests", ApprovalRequestController.createRequest);
|
||||||
router.post("/requests/:id/cancel", ApprovalRequestController.cancelRequest);
|
router.post("/requests/:id/cancel", ApprovalRequestController.cancelRequest);
|
||||||
|
router.post("/requests/:id/post-approve", ApprovalRequestController.postApprove);
|
||||||
|
|
||||||
// ==================== 결재 라인 처리 (Lines) ====================
|
// ==================== 결재 라인 처리 (Lines) ====================
|
||||||
router.get("/my-pending", ApprovalLineController.getMyPendingLines);
|
router.get("/my-pending", ApprovalLineController.getMyPendingLines);
|
||||||
router.post("/lines/:lineId/process", ApprovalLineController.processApproval);
|
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;
|
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;
|
||||||
|
|
@ -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,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 { LoginForm } from "@/components/auth/LoginForm";
|
||||||
import { LoginFooter } from "@/components/auth/LoginFooter";
|
import { LoginFooter } from "@/components/auth/LoginFooter";
|
||||||
|
|
||||||
/**
|
|
||||||
* 로그인 페이지 컴포넌트
|
|
||||||
* 비즈니스 로직은 useLogin 훅에서 처리하고, UI 컴포넌트들을 조합하여 구성
|
|
||||||
*/
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
const { formData, isLoading, error, showPassword, handleInputChange, handleLogin, togglePasswordVisibility } =
|
const { formData, isLoading, error, showPassword, handleInputChange, handleLogin, togglePasswordVisibility } =
|
||||||
useLogin();
|
useLogin();
|
||||||
|
|
||||||
return (
|
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="flex min-h-screen flex-col items-center justify-center bg-muted/40 p-4">
|
||||||
<div className="w-full max-w-md space-y-8">
|
<div className="w-full max-w-md space-y-6">
|
||||||
<LoginHeader />
|
<LoginHeader />
|
||||||
|
|
||||||
<LoginForm
|
<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,
|
string,
|
||||||
{ label: string; icon: React.ElementType; color: 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: { label: "화면", icon: Monitor, color: "bg-purple-100 text-purple-700" },
|
||||||
SCREEN_LAYOUT: { 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: { label: "플로우", icon: GitBranch, color: "bg-emerald-100 text-emerald-700" },
|
||||||
FLOW_STEP: { label: "플로우 스텝", icon: GitBranch, color: "bg-green-100 text-green-700" },
|
FLOW_STEP: { label: "플로우 스텝", icon: GitBranch, color: "bg-emerald-100 text-emerald-700" },
|
||||||
USER: { label: "사용자", icon: User, color: "bg-orange-100 text-orange-700" },
|
USER: { label: "사용자", icon: User, color: "bg-amber-100 text-orange-700" },
|
||||||
ROLE: { label: "권한", icon: Shield, color: "bg-red-100 text-red-700" },
|
ROLE: { label: "권한", icon: Shield, color: "bg-destructive/10 text-destructive" },
|
||||||
PERMISSION: { label: "권한", icon: Shield, color: "bg-red-100 text-red-700" },
|
PERMISSION: { label: "권한", icon: Shield, color: "bg-destructive/10 text-destructive" },
|
||||||
COMPANY: { label: "회사", icon: Building2, color: "bg-indigo-100 text-indigo-700" },
|
COMPANY: { label: "회사", icon: Building2, color: "bg-indigo-100 text-indigo-700" },
|
||||||
CODE_CATEGORY: { label: "코드 카테고리", icon: Hash, color: "bg-cyan-100 text-cyan-700" },
|
CODE_CATEGORY: { label: "코드 카테고리", icon: Hash, color: "bg-cyan-100 text-cyan-700" },
|
||||||
CODE: { 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" },
|
DATA: { label: "데이터", icon: Database, color: "bg-muted text-foreground" },
|
||||||
TABLE: { label: "테이블", icon: Database, color: "bg-gray-100 text-gray-700" },
|
TABLE: { label: "테이블", icon: Database, color: "bg-muted text-foreground" },
|
||||||
NUMBERING_RULE: { label: "채번 규칙", icon: FileText, color: "bg-amber-100 text-amber-700" },
|
NUMBERING_RULE: { label: "채번 규칙", icon: FileText, color: "bg-amber-100 text-amber-700" },
|
||||||
BATCH: { label: "배치", icon: RefreshCw, color: "bg-teal-100 text-teal-700" },
|
BATCH: { label: "배치", icon: RefreshCw, color: "bg-teal-100 text-teal-700" },
|
||||||
};
|
};
|
||||||
|
|
||||||
const ACTION_CONFIG: Record<string, { label: string; color: string }> = {
|
const ACTION_CONFIG: Record<string, { label: string; color: string }> = {
|
||||||
CREATE: { label: "생성", color: "bg-emerald-100 text-emerald-700" },
|
CREATE: { label: "생성", color: "bg-emerald-100 text-emerald-700" },
|
||||||
UPDATE: { label: "수정", color: "bg-blue-100 text-blue-700" },
|
UPDATE: { label: "수정", color: "bg-primary/10 text-primary" },
|
||||||
DELETE: { label: "삭제", color: "bg-red-100 text-red-700" },
|
DELETE: { label: "삭제", color: "bg-destructive/10 text-destructive" },
|
||||||
COPY: { label: "복사", color: "bg-violet-100 text-violet-700" },
|
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" },
|
STATUS_CHANGE: { label: "상태변경", color: "bg-amber-100 text-amber-700" },
|
||||||
BATCH_CREATE: { label: "배치생성", color: "bg-emerald-100 text-emerald-700" },
|
BATCH_CREATE: { label: "배치생성", color: "bg-emerald-100 text-emerald-700" },
|
||||||
BATCH_UPDATE: { label: "배치수정", color: "bg-blue-100 text-blue-700" },
|
BATCH_UPDATE: { label: "배치수정", color: "bg-primary/10 text-primary" },
|
||||||
BATCH_DELETE: { label: "배치삭제", color: "bg-red-100 text-red-700" },
|
BATCH_DELETE: { label: "배치삭제", color: "bg-destructive/10 text-destructive" },
|
||||||
};
|
};
|
||||||
|
|
||||||
function formatDateTime(dateStr: string): string {
|
function formatDateTime(dateStr: string): string {
|
||||||
|
|
@ -203,12 +203,12 @@ function renderChanges(changes: Record<string, unknown>) {
|
||||||
<tr className="bg-muted/50">
|
<tr className="bg-muted/50">
|
||||||
<th className="px-3 py-1.5 text-left font-medium">항목</th>
|
<th className="px-3 py-1.5 text-left font-medium">항목</th>
|
||||||
{hasBefore && (
|
{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>
|
</th>
|
||||||
)}
|
)}
|
||||||
{hasAfter && (
|
{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>
|
</th>
|
||||||
)}
|
)}
|
||||||
|
|
@ -234,7 +234,7 @@ function renderChanges(changes: Record<string, unknown>) {
|
||||||
{hasBefore && (
|
{hasBefore && (
|
||||||
<td className="px-3 py-1.5">
|
<td className="px-3 py-1.5">
|
||||||
{row.beforeVal !== null ? (
|
{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}
|
{row.beforeVal}
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -245,7 +245,7 @@ function renderChanges(changes: Record<string, unknown>) {
|
||||||
{hasAfter && (
|
{hasAfter && (
|
||||||
<td className="px-3 py-1.5">
|
<td className="px-3 py-1.5">
|
||||||
{row.afterVal !== null ? (
|
{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}
|
{row.afterVal}
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -460,9 +460,9 @@ export default function AuditLogPage() {
|
||||||
<CardContent className="p-4">
|
<CardContent className="p-4">
|
||||||
<form
|
<form
|
||||||
onSubmit={handleSearch}
|
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>
|
<label className="text-xs font-medium">검색어</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Search className="text-muted-foreground absolute left-2.5 top-2.5 h-4 w-4" />
|
<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>
|
</div>
|
||||||
|
|
||||||
<div className="w-[130px]">
|
<div className="w-full sm:w-[130px]">
|
||||||
<label className="text-xs font-medium">유형</label>
|
<label className="text-xs font-medium">유형</label>
|
||||||
<Select
|
<Select
|
||||||
value={filters.resourceType || "all"}
|
value={filters.resourceType || "all"}
|
||||||
|
|
@ -497,7 +497,7 @@ export default function AuditLogPage() {
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="w-[120px]">
|
<div className="w-full sm:w-[120px]">
|
||||||
<label className="text-xs font-medium">동작</label>
|
<label className="text-xs font-medium">동작</label>
|
||||||
<Select
|
<Select
|
||||||
value={filters.action || "all"}
|
value={filters.action || "all"}
|
||||||
|
|
@ -520,7 +520,7 @@ export default function AuditLogPage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isSuperAdmin && (
|
{isSuperAdmin && (
|
||||||
<div className="w-[160px]">
|
<div className="w-full sm:w-[160px]">
|
||||||
<label className="text-xs font-medium">회사</label>
|
<label className="text-xs font-medium">회사</label>
|
||||||
<Popover open={companyComboOpen} onOpenChange={setCompanyComboOpen}>
|
<Popover open={companyComboOpen} onOpenChange={setCompanyComboOpen}>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
|
|
@ -604,7 +604,7 @@ export default function AuditLogPage() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="w-[160px]">
|
<div className="w-full sm:w-[160px]">
|
||||||
<label className="text-xs font-medium">사용자</label>
|
<label className="text-xs font-medium">사용자</label>
|
||||||
<Popover open={userComboOpen} onOpenChange={setUserComboOpen}>
|
<Popover open={userComboOpen} onOpenChange={setUserComboOpen}>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
|
|
@ -685,7 +685,7 @@ export default function AuditLogPage() {
|
||||||
</Popover>
|
</Popover>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="w-[130px]">
|
<div className="w-full sm:w-[130px]">
|
||||||
<label className="text-xs font-medium">시작일</label>
|
<label className="text-xs font-medium">시작일</label>
|
||||||
<Input
|
<Input
|
||||||
type="date"
|
type="date"
|
||||||
|
|
@ -695,7 +695,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>
|
<label className="text-xs font-medium">종료일</label>
|
||||||
<Input
|
<Input
|
||||||
type="date"
|
type="date"
|
||||||
|
|
@ -705,7 +705,7 @@ export default function AuditLogPage() {
|
||||||
/>
|
/>
|
||||||
</div>
|
</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" />
|
<Filter className="mr-1 h-4 w-4" />
|
||||||
필터 적용
|
필터 적용
|
||||||
</Button>
|
</Button>
|
||||||
|
|
|
||||||
|
|
@ -311,10 +311,10 @@ export default function BatchCreatePage() {
|
||||||
{/* 매핑 설정 */}
|
{/* 매핑 설정 */}
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
{/* FROM 섹션 */}
|
{/* FROM 섹션 */}
|
||||||
<Card className="border-green-200">
|
<Card className="border-emerald-200">
|
||||||
<CardHeader className="bg-green-50">
|
<CardHeader className="bg-emerald-50">
|
||||||
<CardTitle className="text-green-700">FROM (원본 데이터베이스)</CardTitle>
|
<CardTitle className="text-emerald-700">FROM (원본 데이터베이스)</CardTitle>
|
||||||
<p className="text-sm text-green-600">
|
<p className="text-sm text-emerald-600">
|
||||||
1단계: 커넥션을 선택하세요 → 2단계: 테이블을 선택하세요 → 3단계: 컬럼을 클릭해서 매핑하세요
|
1단계: 커넥션을 선택하세요 → 2단계: 테이블을 선택하세요 → 3단계: 컬럼을 클릭해서 매핑하세요
|
||||||
</p>
|
</p>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
@ -365,7 +365,7 @@ export default function BatchCreatePage() {
|
||||||
{/* FROM 컬럼 목록 */}
|
{/* FROM 컬럼 목록 */}
|
||||||
{fromTable && (
|
{fromTable && (
|
||||||
<div className="space-y-2">
|
<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">
|
<div className="border rounded-lg p-4 max-h-80 overflow-y-auto space-y-2">
|
||||||
{fromColumns.map((column) => (
|
{fromColumns.map((column) => (
|
||||||
<div
|
<div
|
||||||
|
|
@ -373,16 +373,16 @@ export default function BatchCreatePage() {
|
||||||
onClick={() => handleFromColumnClick(column)}
|
onClick={() => handleFromColumnClick(column)}
|
||||||
className={`p-3 border rounded cursor-pointer transition-colors ${
|
className={`p-3 border rounded cursor-pointer transition-colors ${
|
||||||
selectedFromColumn?.column_name === column.column_name
|
selectedFromColumn?.column_name === column.column_name
|
||||||
? 'bg-green-100 border-green-300'
|
? 'bg-emerald-100 border-green-300'
|
||||||
: 'hover:bg-gray-50 border-gray-200'
|
: 'hover:bg-muted border-border'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="font-medium">{column.column_name}</div>
|
<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>
|
</div>
|
||||||
))}
|
))}
|
||||||
{fromColumns.length === 0 && fromTable && (
|
{fromColumns.length === 0 && fromTable && (
|
||||||
<div className="text-center text-gray-500 py-4">
|
<div className="text-center text-muted-foreground py-4">
|
||||||
컬럼을 불러오는 중...
|
컬럼을 불러오는 중...
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -393,10 +393,10 @@ export default function BatchCreatePage() {
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* TO 섹션 */}
|
{/* TO 섹션 */}
|
||||||
<Card className="border-red-200">
|
<Card className="border-destructive/20">
|
||||||
<CardHeader className="bg-red-50">
|
<CardHeader className="bg-destructive/10">
|
||||||
<CardTitle className="text-red-700">TO (대상 데이터베이스)</CardTitle>
|
<CardTitle className="text-destructive">TO (대상 데이터베이스)</CardTitle>
|
||||||
<p className="text-sm text-red-600">
|
<p className="text-sm text-destructive">
|
||||||
FROM에서 컬럼을 선택한 후, 여기서 대상 컬럼을 클릭하면 매핑됩니다
|
FROM에서 컬럼을 선택한 후, 여기서 대상 컬럼을 클릭하면 매핑됩니다
|
||||||
</p>
|
</p>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
@ -447,7 +447,7 @@ export default function BatchCreatePage() {
|
||||||
{/* TO 컬럼 목록 */}
|
{/* TO 컬럼 목록 */}
|
||||||
{toTable && (
|
{toTable && (
|
||||||
<div className="space-y-2">
|
<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">
|
<div className="border rounded-lg p-4 max-h-80 overflow-y-auto space-y-2">
|
||||||
{toColumns.map((column) => (
|
{toColumns.map((column) => (
|
||||||
<div
|
<div
|
||||||
|
|
@ -455,16 +455,16 @@ export default function BatchCreatePage() {
|
||||||
onClick={() => handleToColumnClick(column)}
|
onClick={() => handleToColumnClick(column)}
|
||||||
className={`p-3 border rounded cursor-pointer transition-colors ${
|
className={`p-3 border rounded cursor-pointer transition-colors ${
|
||||||
selectedFromColumn
|
selectedFromColumn
|
||||||
? 'hover:bg-red-50 border-gray-200'
|
? 'hover:bg-destructive/10 border-border'
|
||||||
: 'bg-gray-100 border-gray-300 cursor-not-allowed'
|
: 'bg-muted border-input cursor-not-allowed'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="font-medium">{column.column_name}</div>
|
<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>
|
</div>
|
||||||
))}
|
))}
|
||||||
{toColumns.length === 0 && toTable && (
|
{toColumns.length === 0 && toTable && (
|
||||||
<div className="text-center text-gray-500 py-4">
|
<div className="text-center text-muted-foreground py-4">
|
||||||
컬럼을 불러오는 중...
|
컬럼을 불러오는 중...
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -484,22 +484,22 @@ export default function BatchCreatePage() {
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{mappings.map((mapping, index) => (
|
{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="flex items-center space-x-4">
|
||||||
<div className="text-sm">
|
<div className="text-sm">
|
||||||
<div className="font-medium">
|
<div className="font-medium">
|
||||||
{mapping.from_table_name}.{mapping.from_column_name}
|
{mapping.from_table_name}.{mapping.from_column_name}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-gray-500">
|
<div className="text-muted-foreground">
|
||||||
{mapping.from_column_type}
|
{mapping.from_column_type}
|
||||||
</div>
|
</div>
|
||||||
</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="text-sm">
|
||||||
<div className="font-medium">
|
<div className="font-medium">
|
||||||
{mapping.to_table_name}.{mapping.to_column_name}
|
{mapping.to_table_name}.{mapping.to_column_name}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-gray-500">
|
<div className="text-muted-foreground">
|
||||||
{mapping.to_column_type}
|
{mapping.to_column_type}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -508,7 +508,7 @@ export default function BatchCreatePage() {
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => removeMapping(index)}
|
onClick={() => removeMapping(index)}
|
||||||
className="text-red-600 hover:text-red-700"
|
className="text-destructive hover:text-destructive"
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4" />
|
<Trash2 className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
|
||||||
|
|
@ -815,7 +815,7 @@ export default function BatchEditPage() {
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
)}
|
)}
|
||||||
<p className="mt-1 text-xs text-gray-500">
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
{authTokenMode === "direct"
|
{authTokenMode === "direct"
|
||||||
? "API 호출 시 Authorization 헤더에 사용할 토큰을 입력하세요."
|
? "API 호출 시 Authorization 헤더에 사용할 토큰을 입력하세요."
|
||||||
: "auth_tokens 테이블에서 선택한 서비스의 최신 토큰을 사용합니다."}
|
: "auth_tokens 테이블에서 선택한 서비스의 최신 토큰을 사용합니다."}
|
||||||
|
|
@ -874,7 +874,7 @@ export default function BatchEditPage() {
|
||||||
onChange={(e) => setDataArrayPath(e.target.value)}
|
onChange={(e) => setDataArrayPath(e.target.value)}
|
||||||
placeholder="response (예: data.items, results)"
|
placeholder="response (예: data.items, results)"
|
||||||
/>
|
/>
|
||||||
<p className="mt-1 text-xs text-gray-500">
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
API 응답에서 배열 데이터가 있는 경로를 입력하세요. 비워두면 응답 전체를 사용합니다.
|
API 응답에서 배열 데이터가 있는 경로를 입력하세요. 비워두면 응답 전체를 사용합니다.
|
||||||
<br />
|
<br />
|
||||||
예시: response, data.items, result.list
|
예시: response, data.items, result.list
|
||||||
|
|
@ -902,7 +902,7 @@ export default function BatchEditPage() {
|
||||||
className="min-h-[100px]"
|
className="min-h-[100px]"
|
||||||
rows={5}
|
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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -910,7 +910,7 @@ export default function BatchEditPage() {
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="border-t pt-4">
|
<div className="border-t pt-4">
|
||||||
<Label className="text-base font-medium">API 파라미터 설정</Label>
|
<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>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -967,26 +967,26 @@ export default function BatchEditPage() {
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
{apiParamSource === "dynamic" && (
|
{apiParamSource === "dynamic" && (
|
||||||
<p className="mt-1 text-xs text-gray-500">
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
동적값은 배치 실행 시 설정된 값으로 치환됩니다. 예: {"{{user_id}}"} → 실제 사용자 ID
|
동적값은 배치 실행 시 설정된 값으로 치환됩니다. 예: {"{{user_id}}"} → 실제 사용자 ID
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{apiParamType === "url" && (
|
{apiParamType === "url" && (
|
||||||
<div className="rounded-lg bg-blue-50 p-3">
|
<div className="rounded-lg bg-primary/10 p-3">
|
||||||
<div className="text-sm font-medium text-blue-800">URL 파라미터 예시</div>
|
<div className="text-sm font-medium text-primary">URL 파라미터 예시</div>
|
||||||
<div className="mt-1 text-sm text-blue-700">
|
<div className="mt-1 text-sm text-primary">
|
||||||
엔드포인트: /api/users/{`{${apiParamName || "userId"}}`}
|
엔드포인트: /api/users/{`{${apiParamName || "userId"}}`}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{apiParamType === "query" && (
|
{apiParamType === "query" && (
|
||||||
<div className="rounded-lg bg-green-50 p-3">
|
<div className="rounded-lg bg-emerald-50 p-3">
|
||||||
<div className="text-sm font-medium text-green-800">쿼리 파라미터 예시</div>
|
<div className="text-sm font-medium text-emerald-800">쿼리 파라미터 예시</div>
|
||||||
<div className="mt-1 text-sm text-green-700">
|
<div className="mt-1 text-sm text-emerald-700">
|
||||||
실제 호출: {mappings[0]?.from_table_name || "/api/users"}?{apiParamName || "userId"}=
|
실제 호출: {mappings[0]?.from_table_name || "/api/users"}?{apiParamName || "userId"}=
|
||||||
{apiParamValue || "123"}
|
{apiParamValue || "123"}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -3,10 +3,8 @@
|
||||||
import React, { useState, useEffect } from "react";
|
import React, { useState, useEffect } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Plus, Search, Edit, Trash2, TestTube, Filter } from "lucide-react";
|
import { Plus, Search, Edit, Trash2, TestTube } from "lucide-react";
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { showErrorToast } from "@/lib/utils/toastUtils";
|
import { showErrorToast } from "@/lib/utils/toastUtils";
|
||||||
import {
|
import {
|
||||||
|
|
@ -29,9 +27,16 @@ import {
|
||||||
AlertDialogHeader,
|
AlertDialogHeader,
|
||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
} from "@/components/ui/alert-dialog";
|
} 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() {
|
export default function ExternalCallConfigsPage() {
|
||||||
const [configs, setConfigs] = useState<ExternalCallConfig[]>([]);
|
const [configs, setConfigs] = useState<ExternalCallConfigWithDate[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
const [filter, setFilter] = useState<ExternalCallConfigFilter>({
|
const [filter, setFilter] = useState<ExternalCallConfigFilter>({
|
||||||
|
|
@ -50,15 +55,17 @@ export default function ExternalCallConfigsPage() {
|
||||||
const fetchConfigs = async () => {
|
const fetchConfigs = async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const response = await ExternalCallConfigAPI.getConfigs({
|
const filterWithSearch: Record<string, string | undefined> = { ...filter };
|
||||||
...filter,
|
const trimmed = searchQuery.trim();
|
||||||
search: searchQuery.trim() || undefined,
|
if (trimmed) {
|
||||||
});
|
filterWithSearch.search = trimmed;
|
||||||
|
}
|
||||||
|
const response = await ExternalCallConfigAPI.getConfigs(filterWithSearch as ExternalCallConfigFilter);
|
||||||
|
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
setConfigs(response.data || []);
|
setConfigs((response.data || []) as ExternalCallConfigWithDate[]);
|
||||||
} else {
|
} else {
|
||||||
showErrorToast("외부 호출 설정 조회에 실패했습니다", response.message, {
|
showErrorToast("외부 호출 설정 조회에 실패했습니다", response.error, {
|
||||||
guidance: "네트워크 연결을 확인하고 다시 시도해 주세요.",
|
guidance: "네트워크 연결을 확인하고 다시 시도해 주세요.",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -72,9 +79,10 @@ export default function ExternalCallConfigsPage() {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 초기 로드 및 필터/검색 변경 시 재조회
|
// 초기 로드 및 필터 변경 시 재조회
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchConfigs();
|
fetchConfigs();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [filter]);
|
}, [filter]);
|
||||||
|
|
||||||
// 검색 실행
|
// 검색 실행
|
||||||
|
|
@ -118,7 +126,7 @@ export default function ExternalCallConfigsPage() {
|
||||||
toast.success("외부 호출 설정이 삭제되었습니다.");
|
toast.success("외부 호출 설정이 삭제되었습니다.");
|
||||||
fetchConfigs();
|
fetchConfigs();
|
||||||
} else {
|
} else {
|
||||||
showErrorToast("외부 호출 설정 삭제에 실패했습니다", response.message, {
|
showErrorToast("외부 호출 설정 삭제에 실패했습니다", response.error, {
|
||||||
guidance: "잠시 후 다시 시도해 주세요.",
|
guidance: "잠시 후 다시 시도해 주세요.",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -140,10 +148,10 @@ export default function ExternalCallConfigsPage() {
|
||||||
try {
|
try {
|
||||||
const response = await ExternalCallConfigAPI.testConfig(config.id);
|
const response = await ExternalCallConfigAPI.testConfig(config.id);
|
||||||
|
|
||||||
if (response.success && response.data?.success) {
|
if (response.success) {
|
||||||
toast.success(`테스트 성공: ${response.data.message}`);
|
toast.success(`테스트 성공: ${response.message || "정상"}`);
|
||||||
} else {
|
} else {
|
||||||
toast.error(`테스트 실패: ${response.data?.message || response.message}`);
|
toast.error(`테스트 실패: ${response.message || response.error || "알 수 없는 오류"}`);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("외부 호출 설정 테스트 오류:", error);
|
console.error("외부 호출 설정 테스트 오류:", error);
|
||||||
|
|
@ -171,244 +179,283 @@ export default function ExternalCallConfigsPage() {
|
||||||
return API_TYPE_OPTIONS.find((option) => option.value === apiType)?.label || apiType;
|
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 (
|
return (
|
||||||
<div className="flex min-h-screen flex-col bg-background">
|
<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">
|
<div className="space-y-2 border-b pb-4">
|
||||||
<h1 className="text-3xl font-bold tracking-tight">외부 호출 관리</h1>
|
<h1 className="text-3xl font-bold tracking-tight">외부 호출 관리</h1>
|
||||||
<p className="text-sm text-muted-foreground">Discord, Slack, 카카오톡 등 외부 호출 설정을 관리합니다.</p>
|
<p className="text-sm text-muted-foreground">Discord, Slack, 카카오톡 등 외부 호출 설정을 관리합니다.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 검색 및 필터 영역 */}
|
{/* 검색 및 필터 영역 (반응형) */}
|
||||||
<div className="space-y-4">
|
<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="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
<div className="relative w-full sm:w-[300px]">
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
<div className="w-full sm:w-[320px]">
|
<Input
|
||||||
<div className="relative">
|
placeholder="설정 이름 또는 설명으로 검색..."
|
||||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
value={searchQuery}
|
||||||
<Input
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
placeholder="설정 이름 또는 설명으로 검색..."
|
onKeyPress={handleSearchKeyPress}
|
||||||
value={searchQuery}
|
className="h-10 pl-10 text-sm"
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
/>
|
||||||
onKeyPress={handleSearchKeyPress}
|
|
||||||
className="h-10 pl-10 text-sm"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Button onClick={handleSearch} variant="outline" className="h-10 gap-2 text-sm font-medium">
|
|
||||||
<Search className="h-4 w-4" />
|
|
||||||
검색
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={handleAddConfig} className="h-10 gap-2 text-sm font-medium">
|
<Button onClick={handleSearch} variant="outline" className="h-10 gap-2 text-sm font-medium">
|
||||||
<Plus className="h-4 w-4" />
|
<Search className="h-4 w-4" />
|
||||||
새 외부 호출 추가
|
검색
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
<Button onClick={handleAddConfig} className="h-10 gap-2 text-sm font-medium">
|
||||||
{/* 두 번째 줄: 필터 */}
|
<Plus className="h-4 w-4" />
|
||||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
새 외부 호출 추가
|
||||||
<Select
|
</Button>
|
||||||
value={filter.call_type || "all"}
|
|
||||||
onValueChange={(value) =>
|
|
||||||
setFilter((prev) => ({
|
|
||||||
...prev,
|
|
||||||
call_type: value === "all" ? undefined : value,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="h-10">
|
|
||||||
<SelectValue placeholder="호출 타입" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="all">전체</SelectItem>
|
|
||||||
{CALL_TYPE_OPTIONS.map((option) => (
|
|
||||||
<SelectItem key={option.value} value={option.value}>
|
|
||||||
{option.label}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
|
|
||||||
<Select
|
|
||||||
value={filter.api_type || "all"}
|
|
||||||
onValueChange={(value) =>
|
|
||||||
setFilter((prev) => ({
|
|
||||||
...prev,
|
|
||||||
api_type: value === "all" ? undefined : value,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="h-10">
|
|
||||||
<SelectValue placeholder="API 타입" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="all">전체</SelectItem>
|
|
||||||
{API_TYPE_OPTIONS.map((option) => (
|
|
||||||
<SelectItem key={option.value} value={option.value}>
|
|
||||||
{option.label}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
|
|
||||||
<Select
|
|
||||||
value={filter.is_active || "Y"}
|
|
||||||
onValueChange={(value) =>
|
|
||||||
setFilter((prev) => ({
|
|
||||||
...prev,
|
|
||||||
is_active: value,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="h-10">
|
|
||||||
<SelectValue placeholder="상태" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{ACTIVE_STATUS_OPTIONS.map((option) => (
|
|
||||||
<SelectItem key={option.value} value={option.value}>
|
|
||||||
{option.label}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 설정 목록 */}
|
{/* 필터 영역 */}
|
||||||
<div className="rounded-lg border bg-card shadow-sm">
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||||||
{loading ? (
|
<Select
|
||||||
// 로딩 상태
|
value={filter.call_type || "all"}
|
||||||
<div className="flex h-64 items-center justify-center">
|
onValueChange={(value) =>
|
||||||
<div className="text-sm text-muted-foreground">로딩 중...</div>
|
setFilter((prev) => ({
|
||||||
</div>
|
...prev,
|
||||||
) : configs.length === 0 ? (
|
call_type: value === "all" ? undefined : value,
|
||||||
// 빈 상태
|
}))
|
||||||
<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>
|
<SelectTrigger className="h-10">
|
||||||
<p className="text-xs text-muted-foreground">새 외부 호출을 추가해보세요.</p>
|
<SelectValue placeholder="호출 타입" />
|
||||||
</div>
|
</SelectTrigger>
|
||||||
</div>
|
<SelectContent>
|
||||||
) : (
|
<SelectItem value="all">전체</SelectItem>
|
||||||
// 설정 테이블 목록
|
{CALL_TYPE_OPTIONS.map((option) => (
|
||||||
<Table>
|
<SelectItem key={option.value} value={option.value}>
|
||||||
<TableHeader>
|
{option.label}
|
||||||
<TableRow className="border-b bg-muted/50 hover:bg-muted/50">
|
</SelectItem>
|
||||||
<TableHead className="h-12 text-sm font-semibold">설정명</TableHead>
|
))}
|
||||||
<TableHead className="h-12 text-sm font-semibold">호출 타입</TableHead>
|
</SelectContent>
|
||||||
<TableHead className="h-12 text-sm font-semibold">API 타입</TableHead>
|
</Select>
|
||||||
<TableHead className="h-12 text-sm font-semibold">설명</TableHead>
|
|
||||||
<TableHead className="h-12 text-sm font-semibold">상태</TableHead>
|
<Select
|
||||||
<TableHead className="h-12 text-sm font-semibold">생성일</TableHead>
|
value={filter.api_type || "all"}
|
||||||
<TableHead className="h-12 text-center text-sm font-semibold">작업</TableHead>
|
onValueChange={(value) =>
|
||||||
</TableRow>
|
setFilter((prev) => ({
|
||||||
</TableHeader>
|
...prev,
|
||||||
<TableBody>
|
api_type: value === "all" ? undefined : value,
|
||||||
{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">
|
<SelectTrigger className="h-10">
|
||||||
<Badge variant="outline">{getCallTypeLabel(config.call_type)}</Badge>
|
<SelectValue placeholder="API 타입" />
|
||||||
</TableCell>
|
</SelectTrigger>
|
||||||
<TableCell className="h-16 text-sm">
|
<SelectContent>
|
||||||
{config.api_type ? (
|
<SelectItem value="all">전체</SelectItem>
|
||||||
<Badge variant="secondary">{getApiTypeLabel(config.api_type)}</Badge>
|
{API_TYPE_OPTIONS.map((option) => (
|
||||||
) : (
|
<SelectItem key={option.value} value={option.value}>
|
||||||
<span className="text-muted-foreground">-</span>
|
{option.label}
|
||||||
)}
|
</SelectItem>
|
||||||
</TableCell>
|
))}
|
||||||
<TableCell className="h-16 text-sm">
|
</SelectContent>
|
||||||
<div className="max-w-xs">
|
</Select>
|
||||||
{config.description ? (
|
|
||||||
<span className="block truncate text-muted-foreground" title={config.description}>
|
<Select
|
||||||
{config.description}
|
value={filter.is_active || "Y"}
|
||||||
</span>
|
onValueChange={(value) =>
|
||||||
) : (
|
setFilter((prev) => ({
|
||||||
<span className="text-muted-foreground">-</span>
|
...prev,
|
||||||
)}
|
is_active: value,
|
||||||
</div>
|
}))
|
||||||
</TableCell>
|
}
|
||||||
<TableCell className="h-16 text-sm">
|
>
|
||||||
<Badge variant={config.is_active === "Y" ? "default" : "destructive"}>
|
<SelectTrigger className="h-10">
|
||||||
{config.is_active === "Y" ? "활성" : "비활성"}
|
<SelectValue placeholder="상태" />
|
||||||
</Badge>
|
</SelectTrigger>
|
||||||
</TableCell>
|
<SelectContent>
|
||||||
<TableCell className="h-16 text-sm text-muted-foreground">
|
{ACTIVE_STATUS_OPTIONS.map((option) => (
|
||||||
{config.created_date ? new Date(config.created_date).toLocaleDateString() : "-"}
|
<SelectItem key={option.value} value={option.value}>
|
||||||
</TableCell>
|
{option.label}
|
||||||
<TableCell className="h-16 text-sm">
|
</SelectItem>
|
||||||
<div className="flex justify-center gap-1">
|
))}
|
||||||
<Button
|
</SelectContent>
|
||||||
variant="ghost"
|
</Select>
|
||||||
size="icon"
|
</div>
|
||||||
className="h-8 w-8"
|
|
||||||
onClick={() => handleTestConfig(config)}
|
{/* 설정 목록 (ResponsiveDataView) */}
|
||||||
title="테스트"
|
<ResponsiveDataView<ExternalCallConfigWithDate>
|
||||||
>
|
data={configs}
|
||||||
<TestTube className="h-4 w-4" />
|
columns={columns}
|
||||||
</Button>
|
keyExtractor={(c) => String(c.id || c.config_name)}
|
||||||
<Button
|
isLoading={loading}
|
||||||
variant="ghost"
|
emptyMessage="등록된 외부 호출 설정이 없습니다."
|
||||||
size="icon"
|
skeletonCount={5}
|
||||||
className="h-8 w-8"
|
cardTitle={(c) => c.config_name}
|
||||||
onClick={() => handleEditConfig(config)}
|
cardSubtitle={(c) => c.description || "설명 없음"}
|
||||||
title="편집"
|
cardHeaderRight={(c) => (
|
||||||
>
|
<Badge variant={c.is_active === "Y" ? "default" : "destructive"}>
|
||||||
<Edit className="h-4 w-4" />
|
{c.is_active === "Y" ? "활성" : "비활성"}
|
||||||
</Button>
|
</Badge>
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-8 w-8 text-destructive hover:text-destructive"
|
|
||||||
onClick={() => handleDeleteConfig(config)}
|
|
||||||
title="삭제"
|
|
||||||
>
|
|
||||||
<Trash2 className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
cardFields={cardFields}
|
||||||
|
renderActions={(c) => (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
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="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="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>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
actionsWidth="200px"
|
||||||
|
/>
|
||||||
|
|
||||||
{/* 외부 호출 설정 모달 */}
|
{/* 외부 호출 설정 모달 */}
|
||||||
<ExternalCallConfigModal
|
<ExternalCallConfigModal
|
||||||
isOpen={isModalOpen}
|
isOpen={isModalOpen}
|
||||||
onClose={() => setIsModalOpen(false)}
|
onClose={() => setIsModalOpen(false)}
|
||||||
onSave={handleModalSave}
|
onSave={handleModalSave}
|
||||||
editingConfig={editingConfig}
|
editingConfig={editingConfig}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 삭제 확인 다이얼로그 */}
|
{/* 삭제 확인 다이얼로그 */}
|
||||||
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||||
<AlertDialogContent className="max-w-[95vw] sm:max-w-[500px]">
|
<AlertDialogContent className="max-w-[95vw] sm:max-w-[500px]">
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<AlertDialogTitle className="text-base sm:text-lg">외부 호출 설정 삭제</AlertDialogTitle>
|
<AlertDialogTitle className="text-base sm:text-lg">외부 호출 설정 삭제</AlertDialogTitle>
|
||||||
<AlertDialogDescription className="text-xs sm:text-sm">
|
<AlertDialogDescription className="text-xs sm:text-sm">
|
||||||
"{configToDelete?.config_name}" 설정을 삭제하시겠습니까?
|
"{configToDelete?.config_name}" 설정을 삭제하시겠습니까?
|
||||||
<br />이 작업은 되돌릴 수 없습니다.
|
<br />이 작업은 되돌릴 수 없습니다.
|
||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter className="gap-2 sm:gap-0">
|
<AlertDialogFooter className="gap-2 sm:gap-0">
|
||||||
<AlertDialogCancel className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm">
|
<AlertDialogCancel className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm">
|
||||||
취소
|
취소
|
||||||
</AlertDialogCancel>
|
</AlertDialogCancel>
|
||||||
<AlertDialogAction
|
<AlertDialogAction
|
||||||
onClick={confirmDeleteConfig}
|
onClick={confirmDeleteConfig}
|
||||||
className="h-8 flex-1 bg-destructive text-xs hover:bg-destructive/90 sm:h-10 sm:flex-none sm:text-sm"
|
className="h-8 flex-1 bg-destructive text-xs hover:bg-destructive/90 sm:h-10 sm:flex-none sm:text-sm"
|
||||||
>
|
>
|
||||||
삭제
|
삭제
|
||||||
</AlertDialogAction>
|
</AlertDialogAction>
|
||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Scroll to Top 버튼 */}
|
||||||
|
<ScrollToTop />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,10 +4,8 @@ import React, { useState, useEffect } from "react";
|
||||||
import { Plus, Search, Pencil, Trash2, Database, Terminal, Globe } from "lucide-react";
|
import { Plus, Search, Pencil, Trash2, Database, Terminal, Globe } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
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 { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
|
|
@ -24,11 +22,12 @@ import {
|
||||||
ExternalDbConnectionAPI,
|
ExternalDbConnectionAPI,
|
||||||
ExternalDbConnection,
|
ExternalDbConnection,
|
||||||
ExternalDbConnectionFilter,
|
ExternalDbConnectionFilter,
|
||||||
ConnectionTestRequest,
|
|
||||||
} from "@/lib/api/externalDbConnection";
|
} from "@/lib/api/externalDbConnection";
|
||||||
import { ExternalDbConnectionModal } from "@/components/admin/ExternalDbConnectionModal";
|
import { ExternalDbConnectionModal } from "@/components/admin/ExternalDbConnectionModal";
|
||||||
import { SqlQueryModal } from "@/components/admin/SqlQueryModal";
|
import { SqlQueryModal } from "@/components/admin/SqlQueryModal";
|
||||||
import { RestApiConnectionList } from "@/components/admin/RestApiConnectionList";
|
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";
|
type ConnectionTabType = "database" | "rest-api";
|
||||||
|
|
||||||
|
|
@ -102,7 +101,6 @@ export default function ExternalConnectionsPage() {
|
||||||
setSupportedDbTypes([{ value: "ALL", label: "전체" }, ...types]);
|
setSupportedDbTypes([{ value: "ALL", label: "전체" }, ...types]);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("지원 DB 타입 로딩 오류:", error);
|
console.error("지원 DB 타입 로딩 오류:", error);
|
||||||
// 실패 시 기본값 사용
|
|
||||||
setSupportedDbTypes([
|
setSupportedDbTypes([
|
||||||
{ value: "ALL", label: "전체" },
|
{ value: "ALL", label: "전체" },
|
||||||
{ value: "mysql", label: "MySQL" },
|
{ value: "mysql", label: "MySQL" },
|
||||||
|
|
@ -114,45 +112,36 @@ export default function ExternalConnectionsPage() {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 초기 데이터 로딩
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadConnections();
|
loadConnections();
|
||||||
loadSupportedDbTypes();
|
loadSupportedDbTypes();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 필터 변경 시 데이터 재로딩
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadConnections();
|
loadConnections();
|
||||||
}, [searchTerm, dbTypeFilter, activeStatusFilter]);
|
}, [searchTerm, dbTypeFilter, activeStatusFilter]);
|
||||||
|
|
||||||
// 새 연결 추가
|
|
||||||
const handleAddConnection = () => {
|
const handleAddConnection = () => {
|
||||||
setEditingConnection(undefined);
|
setEditingConnection(undefined);
|
||||||
setIsModalOpen(true);
|
setIsModalOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 연결 편집
|
|
||||||
const handleEditConnection = (connection: ExternalDbConnection) => {
|
const handleEditConnection = (connection: ExternalDbConnection) => {
|
||||||
setEditingConnection(connection);
|
setEditingConnection(connection);
|
||||||
setIsModalOpen(true);
|
setIsModalOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 연결 삭제 확인 다이얼로그 열기
|
|
||||||
const handleDeleteConnection = (connection: ExternalDbConnection) => {
|
const handleDeleteConnection = (connection: ExternalDbConnection) => {
|
||||||
setConnectionToDelete(connection);
|
setConnectionToDelete(connection);
|
||||||
setDeleteDialogOpen(true);
|
setDeleteDialogOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 연결 삭제 실행
|
|
||||||
const confirmDeleteConnection = async () => {
|
const confirmDeleteConnection = async () => {
|
||||||
if (!connectionToDelete?.id) return;
|
if (!connectionToDelete?.id) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await ExternalDbConnectionAPI.deleteConnection(connectionToDelete.id);
|
await ExternalDbConnectionAPI.deleteConnection(connectionToDelete.id);
|
||||||
toast({
|
toast({ title: "성공", description: "연결이 삭제되었습니다." });
|
||||||
title: "성공",
|
|
||||||
description: "연결이 삭제되었습니다.",
|
|
||||||
});
|
|
||||||
loadConnections();
|
loadConnections();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("연결 삭제 오류:", error);
|
console.error("연결 삭제 오류:", error);
|
||||||
|
|
@ -167,13 +156,11 @@ export default function ExternalConnectionsPage() {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 연결 삭제 취소
|
|
||||||
const cancelDeleteConnection = () => {
|
const cancelDeleteConnection = () => {
|
||||||
setDeleteDialogOpen(false);
|
setDeleteDialogOpen(false);
|
||||||
setConnectionToDelete(null);
|
setConnectionToDelete(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 연결 테스트
|
|
||||||
const handleTestConnection = async (connection: ExternalDbConnection) => {
|
const handleTestConnection = async (connection: ExternalDbConnection) => {
|
||||||
if (!connection.id) return;
|
if (!connection.id) return;
|
||||||
|
|
||||||
|
|
@ -181,14 +168,10 @@ export default function ExternalConnectionsPage() {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await ExternalDbConnectionAPI.testConnection(connection.id);
|
const result = await ExternalDbConnectionAPI.testConnection(connection.id);
|
||||||
|
|
||||||
setTestResults((prev) => new Map(prev).set(connection.id!, result.success));
|
setTestResults((prev) => new Map(prev).set(connection.id!, result.success));
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
toast({
|
toast({ title: "연결 성공", description: `${connection.connection_name} 연결이 성공했습니다.` });
|
||||||
title: "연결 성공",
|
|
||||||
description: `${connection.connection_name} 연결이 성공했습니다.`,
|
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
toast({
|
toast({
|
||||||
title: "연결 실패",
|
title: "연결 실패",
|
||||||
|
|
@ -199,11 +182,7 @@ export default function ExternalConnectionsPage() {
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("연결 테스트 오류:", error);
|
console.error("연결 테스트 오류:", error);
|
||||||
setTestResults((prev) => new Map(prev).set(connection.id!, false));
|
setTestResults((prev) => new Map(prev).set(connection.id!, false));
|
||||||
toast({
|
toast({ title: "연결 테스트 오류", description: "연결 테스트 중 오류가 발생했습니다.", variant: "destructive" });
|
||||||
title: "연결 테스트 오류",
|
|
||||||
description: "연결 테스트 중 오류가 발생했습니다.",
|
|
||||||
variant: "destructive",
|
|
||||||
});
|
|
||||||
} finally {
|
} finally {
|
||||||
setTestingConnections((prev) => {
|
setTestingConnections((prev) => {
|
||||||
const newSet = new Set(prev);
|
const newSet = new Set(prev);
|
||||||
|
|
@ -213,19 +192,77 @@ export default function ExternalConnectionsPage() {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 모달 저장 처리
|
|
||||||
const handleModalSave = () => {
|
const handleModalSave = () => {
|
||||||
setIsModalOpen(false);
|
setIsModalOpen(false);
|
||||||
setEditingConnection(undefined);
|
setEditingConnection(undefined);
|
||||||
loadConnections();
|
loadConnections();
|
||||||
};
|
};
|
||||||
|
|
||||||
// 모달 취소 처리
|
|
||||||
const handleModalCancel = () => {
|
const handleModalCancel = () => {
|
||||||
setIsModalOpen(false);
|
setIsModalOpen(false);
|
||||||
setEditingConnection(undefined);
|
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 (
|
return (
|
||||||
<div className="flex min-h-screen flex-col bg-background">
|
<div className="flex min-h-screen flex-col bg-background">
|
||||||
<div className="space-y-6 p-6">
|
<div className="space-y-6 p-6">
|
||||||
|
|
@ -237,7 +274,7 @@ export default function ExternalConnectionsPage() {
|
||||||
|
|
||||||
{/* 탭 */}
|
{/* 탭 */}
|
||||||
<Tabs value={activeTab} onValueChange={(value) => setActiveTab(value as ConnectionTabType)}>
|
<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">
|
<TabsTrigger value="database" className="flex items-center gap-2">
|
||||||
<Database className="h-4 w-4" />
|
<Database className="h-4 w-4" />
|
||||||
데이터베이스 연결
|
데이터베이스 연결
|
||||||
|
|
@ -252,8 +289,7 @@ export default function ExternalConnectionsPage() {
|
||||||
<TabsContent value="database" className="space-y-6">
|
<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 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]">
|
<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" />
|
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
|
|
@ -263,8 +299,6 @@ export default function ExternalConnectionsPage() {
|
||||||
className="h-10 pl-10 text-sm"
|
className="h-10 pl-10 text-sm"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* DB 타입 필터 */}
|
|
||||||
<Select value={dbTypeFilter} onValueChange={setDbTypeFilter}>
|
<Select value={dbTypeFilter} onValueChange={setDbTypeFilter}>
|
||||||
<SelectTrigger className="h-10 w-full sm:w-[160px]">
|
<SelectTrigger className="h-10 w-full sm:w-[160px]">
|
||||||
<SelectValue placeholder="DB 타입" />
|
<SelectValue placeholder="DB 타입" />
|
||||||
|
|
@ -277,8 +311,6 @@ export default function ExternalConnectionsPage() {
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
{/* 활성 상태 필터 */}
|
|
||||||
<Select value={activeStatusFilter} onValueChange={setActiveStatusFilter}>
|
<Select value={activeStatusFilter} onValueChange={setActiveStatusFilter}>
|
||||||
<SelectTrigger className="h-10 w-full sm:w-[120px]">
|
<SelectTrigger className="h-10 w-full sm:w-[120px]">
|
||||||
<SelectValue placeholder="상태" />
|
<SelectValue placeholder="상태" />
|
||||||
|
|
@ -292,126 +324,63 @@ export default function ExternalConnectionsPage() {
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 추가 버튼 */}
|
|
||||||
<Button onClick={handleAddConnection} className="h-10 gap-2 text-sm font-medium">
|
<Button onClick={handleAddConnection} className="h-10 gap-2 text-sm font-medium">
|
||||||
<Plus className="h-4 w-4" />
|
<Plus className="h-4 w-4" />
|
||||||
새 연결 추가
|
새 연결 추가
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 연결 목록 */}
|
{/* 연결 목록 - ResponsiveDataView */}
|
||||||
{loading ? (
|
<ResponsiveDataView
|
||||||
<div className="flex h-64 items-center justify-center bg-card">
|
data={connections}
|
||||||
<div className="text-sm text-muted-foreground">로딩 중...</div>
|
columns={columns}
|
||||||
</div>
|
keyExtractor={(c) => String(c.id || c.connection_name)}
|
||||||
) : connections.length === 0 ? (
|
isLoading={loading}
|
||||||
<div className="flex h-64 flex-col items-center justify-center bg-card">
|
emptyMessage="등록된 연결이 없습니다"
|
||||||
<div className="flex flex-col items-center gap-2 text-center">
|
skeletonCount={5}
|
||||||
<p className="text-sm text-muted-foreground">등록된 연결이 없습니다</p>
|
cardTitle={(c) => c.connection_name}
|
||||||
</div>
|
cardSubtitle={(c) => <span className="font-mono text-xs">{c.host}:{c.port}/{c.database_name}</span>}
|
||||||
</div>
|
cardHeaderRight={(c) => (
|
||||||
) : (
|
<Badge variant={c.is_active === "Y" ? "default" : "secondary"}>
|
||||||
<div className="bg-card">
|
{c.is_active === "Y" ? "활성" : "비활성"}
|
||||||
<Table>
|
</Badge>
|
||||||
<TableHeader>
|
)}
|
||||||
<TableRow className="bg-background">
|
cardFields={cardFields}
|
||||||
<TableHead className="h-12 px-6 py-3 text-sm font-semibold">연결명</TableHead>
|
renderActions={(c) => (
|
||||||
<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>
|
<Button variant="outline" size="sm"
|
||||||
<TableHead className="h-12 px-6 py-3 text-sm font-semibold">호스트:포트</TableHead>
|
onClick={(e) => { e.stopPropagation(); handleTestConnection(c); }}
|
||||||
<TableHead className="h-12 px-6 py-3 text-sm font-semibold">데이터베이스</TableHead>
|
disabled={testingConnections.has(c.id!)}
|
||||||
<TableHead className="h-12 px-6 py-3 text-sm font-semibold">사용자</TableHead>
|
className="h-9 flex-1 gap-2 text-sm">
|
||||||
<TableHead className="h-12 px-6 py-3 text-sm font-semibold">상태</TableHead>
|
{testingConnections.has(c.id!) ? "테스트 중..." : "테스트"}
|
||||||
<TableHead className="h-12 px-6 py-3 text-sm font-semibold">생성일</TableHead>
|
</Button>
|
||||||
<TableHead className="h-12 px-6 py-3 text-sm font-semibold">연결 테스트</TableHead>
|
<Button variant="outline" size="sm"
|
||||||
<TableHead className="h-12 px-6 py-3 text-right text-sm font-semibold">작업</TableHead>
|
onClick={(e) => {
|
||||||
</TableRow>
|
e.stopPropagation();
|
||||||
</TableHeader>
|
setSelectedConnection(c);
|
||||||
<TableBody>
|
setSqlModalOpen(true);
|
||||||
{connections.map((connection) => (
|
}}
|
||||||
<TableRow key={connection.id} className="bg-background transition-colors hover:bg-muted/50">
|
className="h-9 flex-1 gap-2 text-sm">
|
||||||
<TableCell className="h-16 px-6 py-3 text-sm">
|
<Terminal className="h-4 w-4" />
|
||||||
<div className="font-medium">{connection.connection_name}</div>
|
SQL
|
||||||
</TableCell>
|
</Button>
|
||||||
<TableCell className="h-16 px-6 py-3 text-sm">
|
<Button variant="outline" size="sm"
|
||||||
{(connection as any).company_name || connection.company_code}
|
onClick={(e) => { e.stopPropagation(); handleEditConnection(c); }}
|
||||||
</TableCell>
|
className="h-9 flex-1 gap-2 text-sm">
|
||||||
<TableCell className="h-16 px-6 py-3 text-sm">
|
<Pencil className="h-4 w-4" />
|
||||||
<Badge variant="outline">
|
편집
|
||||||
{DB_TYPE_LABELS[connection.db_type] || connection.db_type}
|
</Button>
|
||||||
</Badge>
|
<Button variant="outline" size="sm"
|
||||||
</TableCell>
|
onClick={(e) => { e.stopPropagation(); handleDeleteConnection(c); }}
|
||||||
<TableCell className="h-16 px-6 py-3 font-mono text-sm">
|
className="text-destructive hover:bg-destructive/10 hover:text-destructive h-9 flex-1 gap-2 text-sm">
|
||||||
{connection.host}:{connection.port}
|
<Trash2 className="h-4 w-4" />
|
||||||
</TableCell>
|
삭제
|
||||||
<TableCell className="h-16 px-6 py-3 font-mono text-sm">{connection.database_name}</TableCell>
|
</Button>
|
||||||
<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"}>
|
actionsLabel="작업"
|
||||||
{connection.is_active === "Y" ? "활성" : "비활성"}
|
actionsWidth="180px"
|
||||||
</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!) ? "성공" : "실패"}
|
|
||||||
</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);
|
|
||||||
setSqlModalOpen(true);
|
|
||||||
}}
|
|
||||||
className="h-8 w-8"
|
|
||||||
title="SQL 쿼리 실행"
|
|
||||||
>
|
|
||||||
<Terminal className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
onClick={() => handleEditConnection(connection)}
|
|
||||||
className="h-8 w-8"
|
|
||||||
>
|
|
||||||
<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"
|
|
||||||
>
|
|
||||||
<Trash2 className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 연결 설정 모달 */}
|
{/* 연결 설정 모달 */}
|
||||||
{isModalOpen && (
|
{isModalOpen && (
|
||||||
|
|
@ -430,7 +399,7 @@ export default function ExternalConnectionsPage() {
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<AlertDialogTitle className="text-base sm:text-lg">연결 삭제 확인</AlertDialogTitle>
|
<AlertDialogTitle className="text-base sm:text-lg">연결 삭제 확인</AlertDialogTitle>
|
||||||
<AlertDialogDescription className="text-xs sm:text-sm">
|
<AlertDialogDescription className="text-xs sm:text-sm">
|
||||||
"{connectionToDelete?.connection_name}" 연결을 삭제하시겠습니까?
|
“{connectionToDelete?.connection_name}” 연결을 삭제하시겠습니까?
|
||||||
<br />
|
<br />
|
||||||
이 작업은 되돌릴 수 없습니다.
|
이 작업은 되돌릴 수 없습니다.
|
||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
|
|
@ -472,6 +441,7 @@ export default function ExternalConnectionsPage() {
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
|
<ScrollToTop />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -294,7 +294,7 @@ export default function FlowEditorPage() {
|
||||||
onNodeDragStop={handleNodeDragStop}
|
onNodeDragStop={handleNodeDragStop}
|
||||||
nodeTypes={nodeTypes}
|
nodeTypes={nodeTypes}
|
||||||
fitView
|
fitView
|
||||||
className="bg-gray-50"
|
className="bg-muted"
|
||||||
>
|
>
|
||||||
<Background />
|
<Background />
|
||||||
<Controls />
|
<Controls />
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
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 { Button } from "@/components/ui/button";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import {
|
import {
|
||||||
|
|
@ -33,8 +33,9 @@ import { cn } from "@/lib/utils";
|
||||||
import { formatErrorMessage } from "@/lib/utils/errorUtils";
|
import { formatErrorMessage } from "@/lib/utils/errorUtils";
|
||||||
import { tableManagementApi } from "@/lib/api/tableManagement";
|
import { tableManagementApi } from "@/lib/api/tableManagement";
|
||||||
import { ScrollToTop } from "@/components/common/ScrollToTop";
|
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 { ExternalRestApiConnectionAPI, ExternalRestApiConnection } from "@/lib/api/externalRestApiConnection";
|
||||||
|
import { ResponsiveDataView, RDVColumn, RDVCardField } from "@/components/common/ResponsiveDataView";
|
||||||
|
|
||||||
export default function FlowManagementPage() {
|
export default function FlowManagementPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
@ -55,9 +56,7 @@ export default function FlowManagementPage() {
|
||||||
const [openTableCombobox, setOpenTableCombobox] = useState(false);
|
const [openTableCombobox, setOpenTableCombobox] = useState(false);
|
||||||
// 데이터 소스 타입: "internal" (내부 DB), "external_db_숫자" (외부 DB), "restapi_숫자" (REST API)
|
// 데이터 소스 타입: "internal" (내부 DB), "external_db_숫자" (외부 DB), "restapi_숫자" (REST API)
|
||||||
const [selectedDbSource, setSelectedDbSource] = useState<string>("internal");
|
const [selectedDbSource, setSelectedDbSource] = useState<string>("internal");
|
||||||
const [externalConnections, setExternalConnections] = useState<
|
const [externalConnections, setExternalConnections] = useState<ExternalDbConnection[]>([]);
|
||||||
Array<{ id: number; connection_name: string; db_type: string }>
|
|
||||||
>([]);
|
|
||||||
const [externalTableList, setExternalTableList] = useState<string[]>([]);
|
const [externalTableList, setExternalTableList] = useState<string[]>([]);
|
||||||
const [loadingExternalTables, setLoadingExternalTables] = useState(false);
|
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 }) =>
|
.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,
|
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 }));
|
setMultiDbTableLists(prev => ({ ...prev, [connectionId]: tableNames }));
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -446,7 +445,7 @@ export default function FlowManagementPage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("✅ Calling createFlowDefinition with:", requestData);
|
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) {
|
if (response.success && response.data) {
|
||||||
toast({
|
toast({
|
||||||
title: "생성 완료",
|
title: "생성 완료",
|
||||||
|
|
@ -513,6 +512,81 @@ export default function FlowManagementPage() {
|
||||||
router.push(`/admin/flow-management/${flowId}`);
|
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 (
|
return (
|
||||||
<div className="bg-background flex min-h-screen flex-col">
|
<div className="bg-background flex min-h-screen flex-col">
|
||||||
<div className="space-y-6 p-4 sm:p-6">
|
<div className="space-y-6 p-4 sm:p-6">
|
||||||
|
|
@ -522,123 +596,74 @@ export default function FlowManagementPage() {
|
||||||
<p className="text-muted-foreground text-sm">업무 프로세스 플로우를 생성하고 관리합니다</p>
|
<p className="text-muted-foreground text-sm">업무 프로세스 플로우를 생성하고 관리합니다</p>
|
||||||
</div>
|
</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">
|
<Button onClick={() => setIsCreateDialogOpen(true)} className="h-10 gap-2 text-sm font-medium">
|
||||||
<Plus className="h-4 w-4" />새 플로우 생성
|
<Plus className="h-4 w-4" />새 플로우 생성
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 플로우 카드 목록 */}
|
{/* 플로우 목록 (ResponsiveDataView) */}
|
||||||
{loading ? (
|
<ResponsiveDataView<FlowDefinition>
|
||||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
data={filteredFlows}
|
||||||
{Array.from({ length: 6 }).map((_, index) => (
|
columns={columns}
|
||||||
<div key={index} className="bg-card rounded-lg border p-6 shadow-sm">
|
keyExtractor={(f) => String(f.id)}
|
||||||
<div className="mb-4 space-y-2">
|
isLoading={loading}
|
||||||
<div className="bg-muted h-5 w-32 animate-pulse rounded"></div>
|
emptyMessage="생성된 플로우가 없습니다."
|
||||||
<div className="bg-muted h-4 w-full animate-pulse rounded"></div>
|
skeletonCount={6}
|
||||||
<div className="bg-muted h-4 w-3/4 animate-pulse rounded"></div>
|
cardTitle={(f) => f.name}
|
||||||
</div>
|
cardSubtitle={(f) => f.description || "설명 없음"}
|
||||||
<div className="space-y-2 border-t pt-4">
|
cardHeaderRight={(f) =>
|
||||||
{Array.from({ length: 3 }).map((_, i) => (
|
f.isActive ? (
|
||||||
<div key={i} className="flex items-center gap-2">
|
<Badge variant="default" className="shrink-0">활성</Badge>
|
||||||
<div className="bg-muted h-4 w-4 animate-pulse rounded"></div>
|
) : null
|
||||||
<div className="bg-muted h-4 flex-1 animate-pulse rounded"></div>
|
}
|
||||||
</div>
|
cardFields={cardFields}
|
||||||
))}
|
onRowClick={(f) => handleEdit(f.id)}
|
||||||
</div>
|
renderActions={(f) => (
|
||||||
<div className="mt-4 flex gap-2 border-t pt-4">
|
<>
|
||||||
<div className="bg-muted h-9 flex-1 animate-pulse rounded"></div>
|
<Button
|
||||||
<div className="bg-muted h-9 w-9 animate-pulse rounded"></div>
|
variant="outline"
|
||||||
</div>
|
size="sm"
|
||||||
</div>
|
className="h-9 flex-1 gap-2 text-sm"
|
||||||
))}
|
onClick={(e) => {
|
||||||
</div>
|
e.stopPropagation();
|
||||||
) : flows.length === 0 ? (
|
handleEdit(f.id);
|
||||||
<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)}
|
|
||||||
>
|
>
|
||||||
{/* 헤더 */}
|
<Edit2 className="h-4 w-4" />
|
||||||
<div className="mb-4 flex items-start justify-between">
|
편집
|
||||||
<div className="min-w-0 flex-1">
|
</Button>
|
||||||
<div className="flex items-center gap-2">
|
<Button
|
||||||
<h3 className="truncate text-base font-semibold">{flow.name}</h3>
|
variant="outline"
|
||||||
{flow.isActive && (
|
size="sm"
|
||||||
<Badge className="shrink-0 bg-emerald-500 text-white hover:bg-emerald-600">활성</Badge>
|
className="h-9 w-9 p-0"
|
||||||
)}
|
onClick={(e) => {
|
||||||
</div>
|
e.stopPropagation();
|
||||||
<p className="text-muted-foreground mt-1 line-clamp-2 text-sm">{flow.description || "설명 없음"}</p>
|
setSelectedFlow(f);
|
||||||
</div>
|
setIsDeleteDialogOpen(true);
|
||||||
</div>
|
}}
|
||||||
|
>
|
||||||
{/* 정보 */}
|
<Trash2 className="h-4 w-4" />
|
||||||
<div className="space-y-2 border-t pt-4">
|
</Button>
|
||||||
<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>
|
actionsWidth="160px"
|
||||||
</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">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="h-9 flex-1 gap-2 text-sm"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
handleEdit(flow.id);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Edit2 className="h-4 w-4" />
|
|
||||||
편집
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="h-9 w-9 p-0"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
setSelectedFlow(flow);
|
|
||||||
setIsDeleteDialogOpen(true);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Trash2 className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 생성 다이얼로그 */}
|
{/* 생성 다이얼로그 */}
|
||||||
<Dialog open={isCreateDialogOpen} onOpenChange={setIsCreateDialogOpen}>
|
<Dialog open={isCreateDialogOpen} onOpenChange={setIsCreateDialogOpen}>
|
||||||
|
|
@ -996,7 +1021,7 @@ export default function FlowManagementPage() {
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<span className="font-medium">{table.displayName || table.tableName}</span>
|
<span className="font-medium">{table.displayName || table.tableName}</span>
|
||||||
{table.description && (
|
{table.description && (
|
||||||
<span className="text-[10px] text-gray-500">{table.description}</span>
|
<span className="text-[10px] text-muted-foreground">{table.description}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
|
|
|
||||||
|
|
@ -409,7 +409,7 @@ example2@example.com,김철수,XYZ회사`;
|
||||||
{recipients.length > 0 && (
|
{recipients.length > 0 && (
|
||||||
<div className="rounded-md border bg-muted p-4">
|
<div className="rounded-md border bg-muted p-4">
|
||||||
<div className="flex items-center gap-2 text-sm">
|
<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>
|
<span className="font-medium">{recipients.length}명의 수신자</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-1 text-xs text-muted-foreground">
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
|
|
|
||||||
|
|
@ -101,8 +101,8 @@ export default function MailDashboardPage() {
|
||||||
value: stats.totalAccounts,
|
value: stats.totalAccounts,
|
||||||
icon: Users,
|
icon: Users,
|
||||||
color: "blue",
|
color: "blue",
|
||||||
bgColor: "bg-blue-100",
|
bgColor: "bg-primary/10",
|
||||||
iconColor: "text-blue-600",
|
iconColor: "text-primary",
|
||||||
href: "/admin/mail/accounts",
|
href: "/admin/mail/accounts",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -110,8 +110,8 @@ export default function MailDashboardPage() {
|
||||||
value: stats.totalTemplates,
|
value: stats.totalTemplates,
|
||||||
icon: FileText,
|
icon: FileText,
|
||||||
color: "green",
|
color: "green",
|
||||||
bgColor: "bg-green-100",
|
bgColor: "bg-emerald-100",
|
||||||
iconColor: "text-green-600",
|
iconColor: "text-emerald-600",
|
||||||
href: "/admin/mail/templates",
|
href: "/admin/mail/templates",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -119,8 +119,8 @@ export default function MailDashboardPage() {
|
||||||
value: stats.sentToday,
|
value: stats.sentToday,
|
||||||
icon: Send,
|
icon: Send,
|
||||||
color: "orange",
|
color: "orange",
|
||||||
bgColor: "bg-orange-100",
|
bgColor: "bg-amber-100",
|
||||||
iconColor: "text-orange-600",
|
iconColor: "text-amber-600",
|
||||||
href: "/admin/mail/sent",
|
href: "/admin/mail/sent",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -438,8 +438,8 @@ export default function MailReceivePage() {
|
||||||
<div
|
<div
|
||||||
className={`mt-4 p-3 rounded-lg flex items-center gap-2 ${
|
className={`mt-4 p-3 rounded-lg flex items-center gap-2 ${
|
||||||
testResult.success
|
testResult.success
|
||||||
? "bg-green-50 text-green-800 border border-green-200"
|
? "bg-emerald-50 text-emerald-800 border border-emerald-200"
|
||||||
: "bg-red-50 text-red-800 border border-red-200"
|
: "bg-destructive/10 text-red-800 border border-destructive/20"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{testResult.success ? (
|
{testResult.success ? (
|
||||||
|
|
@ -460,7 +460,7 @@ export default function MailReceivePage() {
|
||||||
<div className="flex flex-col md:flex-row gap-3">
|
<div className="flex flex-col md:flex-row gap-3">
|
||||||
{/* 검색 */}
|
{/* 검색 */}
|
||||||
<div className="flex-1 relative">
|
<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
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
|
|
@ -511,7 +511,7 @@ export default function MailReceivePage() {
|
||||||
{filteredAndSortedMails.length}개의 메일이 검색되었습니다
|
{filteredAndSortedMails.length}개의 메일이 검색되었습니다
|
||||||
{searchTerm && (
|
{searchTerm && (
|
||||||
<span className="ml-2">
|
<span className="ml-2">
|
||||||
(검색어: <span className="font-medium text-orange-600">{searchTerm}</span>)
|
(검색어: <span className="font-medium text-amber-600">{searchTerm}</span>)
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -527,14 +527,14 @@ export default function MailReceivePage() {
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<Card className="">
|
<Card className="">
|
||||||
<CardContent className="flex justify-center items-center py-16">
|
<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>
|
<span className="ml-3 text-muted-foreground">메일을 불러오는 중...</span>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
) : filteredAndSortedMails.length === 0 ? (
|
) : filteredAndSortedMails.length === 0 ? (
|
||||||
<Card className="text-center py-16 bg-card ">
|
<Card className="text-center py-16 bg-card ">
|
||||||
<CardContent className="pt-6">
|
<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">
|
<p className="text-muted-foreground mb-4">
|
||||||
{!selectedAccountId
|
{!selectedAccountId
|
||||||
? "메일 계정을 선택하세요"
|
? "메일 계정을 선택하세요"
|
||||||
|
|
@ -560,9 +560,9 @@ export default function MailReceivePage() {
|
||||||
</Card>
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
<Card className="">
|
<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">
|
<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}개)
|
받은 메일함 ({filteredAndSortedMails.length}/{mails.length}개)
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
@ -573,14 +573,14 @@ export default function MailReceivePage() {
|
||||||
key={mail.id}
|
key={mail.id}
|
||||||
onClick={() => handleMailClick(mail)}
|
onClick={() => handleMailClick(mail)}
|
||||||
className={`p-4 hover:bg-background transition-colors cursor-pointer ${
|
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" : ""}`}
|
} ${selectedMailId === mail.id ? "bg-accent border-l-4 border-l-primary" : ""}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-start gap-4">
|
<div className="flex items-start gap-4">
|
||||||
{/* 읽음 표시 */}
|
{/* 읽음 표시 */}
|
||||||
<div className="flex-shrink-0 w-2 h-2 mt-2">
|
<div className="flex-shrink-0 w-2 h-2 mt-2">
|
||||||
{!mail.isRead && (
|
{!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>
|
</div>
|
||||||
|
|
||||||
|
|
@ -598,7 +598,7 @@ export default function MailReceivePage() {
|
||||||
</span>
|
</span>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{mail.hasAttachments && (
|
{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">
|
<span className="text-xs text-muted-foreground">
|
||||||
{formatDate(mail.date)}
|
{formatDate(mail.date)}
|
||||||
|
|
@ -882,14 +882,14 @@ export default function MailReceivePage() {
|
||||||
) : loadingDetail ? (
|
) : loadingDetail ? (
|
||||||
<Card className="sticky top-6">
|
<Card className="sticky top-6">
|
||||||
<CardContent className="flex justify-center items-center py-16">
|
<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>
|
<span className="ml-3 text-muted-foreground">메일을 불러오는 중...</span>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
<Card className="sticky top-6">
|
<Card className="sticky top-6">
|
||||||
<CardContent className="flex flex-col justify-center items-center py-16 text-center">
|
<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 className="text-muted-foreground">
|
||||||
메일을 선택하면 내용이 표시됩니다
|
메일을 선택하면 내용이 표시됩니다
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -900,10 +900,10 @@ export default function MailReceivePage() {
|
||||||
</div>
|
</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>
|
<CardHeader>
|
||||||
<CardTitle className="text-lg flex items-center">
|
<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>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
@ -913,81 +913,81 @@ export default function MailReceivePage() {
|
||||||
</p>
|
</p>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||||
<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">
|
<ul className="space-y-1 text-sm text-muted-foreground">
|
||||||
<li className="flex items-start">
|
<li className="flex items-start">
|
||||||
<span className="text-green-500 mr-2">✓</span>
|
<span className="text-emerald-500 mr-2">✓</span>
|
||||||
<span>IMAP 프로토콜 메일 수신</span>
|
<span>IMAP 프로토콜 메일 수신</span>
|
||||||
</li>
|
</li>
|
||||||
<li className="flex items-start">
|
<li className="flex items-start">
|
||||||
<span className="text-green-500 mr-2">✓</span>
|
<span className="text-emerald-500 mr-2">✓</span>
|
||||||
<span>메일 목록 표시</span>
|
<span>메일 목록 표시</span>
|
||||||
</li>
|
</li>
|
||||||
<li className="flex items-start">
|
<li className="flex items-start">
|
||||||
<span className="text-green-500 mr-2">✓</span>
|
<span className="text-emerald-500 mr-2">✓</span>
|
||||||
<span>읽음/안읽음 상태</span>
|
<span>읽음/안읽음 상태</span>
|
||||||
</li>
|
</li>
|
||||||
<li className="flex items-start">
|
<li className="flex items-start">
|
||||||
<span className="text-green-500 mr-2">✓</span>
|
<span className="text-emerald-500 mr-2">✓</span>
|
||||||
<span>첨부파일 유무 표시</span>
|
<span>첨부파일 유무 표시</span>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<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">
|
<ul className="space-y-1 text-sm text-muted-foreground">
|
||||||
<li className="flex items-start">
|
<li className="flex items-start">
|
||||||
<span className="text-green-500 mr-2">✓</span>
|
<span className="text-emerald-500 mr-2">✓</span>
|
||||||
<span>HTML 본문 렌더링</span>
|
<span>HTML 본문 렌더링</span>
|
||||||
</li>
|
</li>
|
||||||
<li className="flex items-start">
|
<li className="flex items-start">
|
||||||
<span className="text-green-500 mr-2">✓</span>
|
<span className="text-emerald-500 mr-2">✓</span>
|
||||||
<span>텍스트 본문 보기</span>
|
<span>텍스트 본문 보기</span>
|
||||||
</li>
|
</li>
|
||||||
<li className="flex items-start">
|
<li className="flex items-start">
|
||||||
<span className="text-green-500 mr-2">✓</span>
|
<span className="text-emerald-500 mr-2">✓</span>
|
||||||
<span>자동 읽음 처리</span>
|
<span>자동 읽음 처리</span>
|
||||||
</li>
|
</li>
|
||||||
<li className="flex items-start">
|
<li className="flex items-start">
|
||||||
<span className="text-green-500 mr-2">✓</span>
|
<span className="text-emerald-500 mr-2">✓</span>
|
||||||
<span>첨부파일 다운로드</span>
|
<span>첨부파일 다운로드</span>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<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">
|
<ul className="space-y-1 text-sm text-muted-foreground">
|
||||||
<li className="flex items-start">
|
<li className="flex items-start">
|
||||||
<span className="text-green-500 mr-2">✓</span>
|
<span className="text-emerald-500 mr-2">✓</span>
|
||||||
<span>통합 검색 (제목/발신자/내용)</span>
|
<span>통합 검색 (제목/발신자/내용)</span>
|
||||||
</li>
|
</li>
|
||||||
<li className="flex items-start">
|
<li className="flex items-start">
|
||||||
<span className="text-green-500 mr-2">✓</span>
|
<span className="text-emerald-500 mr-2">✓</span>
|
||||||
<span>필터링 (읽음/첨부파일)</span>
|
<span>필터링 (읽음/첨부파일)</span>
|
||||||
</li>
|
</li>
|
||||||
<li className="flex items-start">
|
<li className="flex items-start">
|
||||||
<span className="text-green-500 mr-2">✓</span>
|
<span className="text-emerald-500 mr-2">✓</span>
|
||||||
<span>정렬 (날짜/발신자)</span>
|
<span>정렬 (날짜/발신자)</span>
|
||||||
</li>
|
</li>
|
||||||
<li className="flex items-start">
|
<li className="flex items-start">
|
||||||
<span className="text-green-500 mr-2">✓</span>
|
<span className="text-emerald-500 mr-2">✓</span>
|
||||||
<span>자동 새로고침 (30초)</span>
|
<span>자동 새로고침 (30초)</span>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<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">
|
<ul className="space-y-1 text-sm text-muted-foreground">
|
||||||
<li className="flex items-start">
|
<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>
|
<span>XSS 방지 (DOMPurify)</span>
|
||||||
</li>
|
</li>
|
||||||
<li className="flex items-start">
|
<li className="flex items-start">
|
||||||
<span className="text-green-500 mr-2">✓</span>
|
<span className="text-emerald-500 mr-2">✓</span>
|
||||||
<span>비밀번호 암호화</span>
|
<span>비밀번호 암호화</span>
|
||||||
</li>
|
</li>
|
||||||
<li className="flex items-start">
|
<li className="flex items-start">
|
||||||
<span className="text-green-500 mr-2">✓</span>
|
<span className="text-emerald-500 mr-2">✓</span>
|
||||||
<span>안전한 파일명 생성</span>
|
<span>안전한 파일명 생성</span>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
|
||||||
|
|
@ -516,12 +516,12 @@ ${data.originalBody}`;
|
||||||
toast({
|
toast({
|
||||||
title: (
|
title: (
|
||||||
<div className="flex items-center gap-2">
|
<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>
|
<span>메일 발송 완료!</span>
|
||||||
</div>
|
</div>
|
||||||
) as any,
|
) as any,
|
||||||
description: `${to.length}명${cc.length > 0 ? ` (참조 ${cc.length}명)` : ""}${bcc.length > 0 ? ` (숨은참조 ${bcc.length}명)` : ""}${attachments.length > 0 ? ` (첨부파일 ${attachments.length}개)` : ""}에게 메일이 성공적으로 발송되었습니다.`,
|
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({
|
toast({
|
||||||
title: (
|
title: (
|
||||||
<div className="flex items-center gap-2">
|
<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>
|
<span>메일 발송 실패</span>
|
||||||
</div>
|
</div>
|
||||||
) as any,
|
) 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>
|
<span>
|
||||||
{new Date(lastSaved).toLocaleTimeString('ko-KR', {
|
{new Date(lastSaved).toLocaleTimeString('ko-KR', {
|
||||||
hour: '2-digit',
|
hour: '2-digit',
|
||||||
|
|
@ -895,7 +895,7 @@ ${data.originalBody}`;
|
||||||
{to.map((email) => (
|
{to.map((email) => (
|
||||||
<div
|
<div
|
||||||
key={email}
|
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>
|
<span>{email}</span>
|
||||||
<button
|
<button
|
||||||
|
|
@ -933,12 +933,12 @@ ${data.originalBody}`;
|
||||||
{cc.map((email) => (
|
{cc.map((email) => (
|
||||||
<div
|
<div
|
||||||
key={email}
|
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>
|
<span>{email}</span>
|
||||||
<button
|
<button
|
||||||
onClick={() => removeEmail(email, "cc")}
|
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" />
|
<X className="w-3 h-3" />
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -1222,7 +1222,7 @@ ${data.originalBody}`;
|
||||||
<div
|
<div
|
||||||
key={component.id}
|
key={component.id}
|
||||||
style={{ height: `${component.height || 20}px` }}
|
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>
|
</div>
|
||||||
|
|
@ -1236,7 +1236,7 @@ ${data.originalBody}`;
|
||||||
{component.logoSrc && <img src={component.logoSrc} alt="로고" className="h-10" />}
|
{component.logoSrc && <img src={component.logoSrc} alt="로고" className="h-10" />}
|
||||||
<span className="font-bold text-lg">{component.brandName}</span>
|
<span className="font-bold text-lg">{component.brandName}</span>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm text-gray-500">{component.sendDate}</span>
|
<span className="text-sm text-muted-foreground">{component.sendDate}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -1245,13 +1245,13 @@ ${data.originalBody}`;
|
||||||
return (
|
return (
|
||||||
<div key={component.id} className="border rounded-lg overflow-hidden">
|
<div key={component.id} className="border rounded-lg overflow-hidden">
|
||||||
{component.tableTitle && (
|
{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">
|
<table className="w-full">
|
||||||
<tbody>
|
<tbody>
|
||||||
{component.rows?.map((row: any, i: number) => (
|
{component.rows?.map((row: any, i: number) => (
|
||||||
<tr key={i} className={i % 2 === 0 ? 'bg-white' : 'bg-gray-50'}>
|
<tr key={i} className={i % 2 === 0 ? 'bg-white' : 'bg-muted'}>
|
||||||
<td className="px-4 py-2 font-medium text-gray-600 w-1/3 border-r">{row.label}</td>
|
<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>
|
<td className="px-4 py-2">{row.value}</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
|
|
@ -1263,9 +1263,9 @@ ${data.originalBody}`;
|
||||||
case 'alertBox':
|
case 'alertBox':
|
||||||
return (
|
return (
|
||||||
<div key={component.id} className={`p-4 rounded-lg border-l-4 ${
|
<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 === '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'
|
'bg-emerald-50 border-emerald-500 text-emerald-800'
|
||||||
}`}>
|
}`}>
|
||||||
{component.alertTitle && <div className="font-bold mb-1">{component.alertTitle}</div>}
|
{component.alertTitle && <div className="font-bold mb-1">{component.alertTitle}</div>}
|
||||||
|
|
@ -1275,13 +1275,13 @@ ${data.originalBody}`;
|
||||||
|
|
||||||
case 'divider':
|
case 'divider':
|
||||||
return (
|
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':
|
case 'footer':
|
||||||
return (
|
return (
|
||||||
<div key={component.id} className="text-center text-sm text-gray-500 py-4 border-t bg-gray-50">
|
<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-gray-700">{component.companyName}</div>}
|
{component.companyName && <div className="font-semibold text-foreground">{component.companyName}</div>}
|
||||||
{(component.ceoName || component.businessNumber) && (
|
{(component.ceoName || component.businessNumber) && (
|
||||||
<div className="mt-1">
|
<div className="mt-1">
|
||||||
{component.ceoName && <span>대표: {component.ceoName}</span>}
|
{component.ceoName && <span>대표: {component.ceoName}</span>}
|
||||||
|
|
@ -1297,7 +1297,7 @@ ${data.originalBody}`;
|
||||||
{component.email && <span>Email: {component.email}</span>}
|
{component.email && <span>Email: {component.email}</span>}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -1318,9 +1318,9 @@ ${data.originalBody}`;
|
||||||
}
|
}
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-gradient-to-r from-green-50 to-emerald-50 px-4 py-3 border-t border-green-200">
|
<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-green-800 flex items-center gap-2 font-medium">
|
<p className="text-sm text-emerald-800 flex items-center gap-2 font-medium">
|
||||||
<CheckCircle2 className="w-4 h-4 text-green-600" />
|
<CheckCircle2 className="w-4 h-4 text-emerald-600" />
|
||||||
위 내용으로 메일이 발송됩니다
|
위 내용으로 메일이 발송됩니다
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1396,7 +1396,7 @@ ${data.originalBody}`;
|
||||||
onChange={handleFileSelect}
|
onChange={handleFileSelect}
|
||||||
className="hidden"
|
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 className="text-sm text-muted-foreground mb-1">
|
||||||
파일을 드래그하거나 클릭하여 선택하세요
|
파일을 드래그하거나 클릭하여 선택하세요
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -1430,7 +1430,7 @@ ${data.originalBody}`;
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => removeFile(index)}
|
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" />
|
<X className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -1530,7 +1530,7 @@ ${data.originalBody}`;
|
||||||
<div key={index} className="flex items-center gap-2 text-xs text-muted-foreground">
|
<div key={index} className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
<File className="w-3 h-3" />
|
<File className="w-3 h-3" />
|
||||||
<span className="truncate">{file.name}</span>
|
<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>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -371,8 +371,8 @@ export default function SentMailPage() {
|
||||||
{stats.successCount}
|
{stats.successCount}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-3 bg-green-500/10 rounded-lg">
|
<div className="p-3 bg-emerald-500/10 rounded-lg">
|
||||||
<CheckCircle2 className="w-6 h-6 text-green-600" />
|
<CheckCircle2 className="w-6 h-6 text-emerald-600" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|
@ -387,8 +387,8 @@ export default function SentMailPage() {
|
||||||
{stats.failedCount}
|
{stats.failedCount}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-3 bg-red-500/10 rounded-lg">
|
<div className="p-3 bg-destructive/10 rounded-lg">
|
||||||
<XCircle className="w-6 h-6 text-red-600" />
|
<XCircle className="w-6 h-6 text-destructive" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|
@ -403,8 +403,8 @@ export default function SentMailPage() {
|
||||||
{stats.todayCount}
|
{stats.todayCount}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-3 bg-blue-500/10 rounded-lg">
|
<div className="p-3 bg-primary/10 rounded-lg">
|
||||||
<Calendar className="w-6 h-6 text-blue-600" />
|
<Calendar className="w-6 h-6 text-primary" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|
|
||||||
|
|
@ -592,19 +592,19 @@ export default function BatchManagementNewPage() {
|
||||||
<div
|
<div
|
||||||
key={option.value}
|
key={option.value}
|
||||||
className={`cursor-pointer rounded-lg border p-3 transition-all ${
|
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)}
|
onClick={() => setBatchType(option.value)}
|
||||||
>
|
>
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
{option.value === "restapi-to-db" ? (
|
{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>
|
||||||
<div className="text-sm font-medium">{option.label}</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -739,7 +739,7 @@ export default function BatchManagementNewPage() {
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
)}
|
)}
|
||||||
<p className="mt-1 text-xs text-gray-500">
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
{authTokenMode === "direct"
|
{authTokenMode === "direct"
|
||||||
? "API 호출 시 Authorization 헤더에 사용할 토큰을 입력하세요."
|
? "API 호출 시 Authorization 헤더에 사용할 토큰을 입력하세요."
|
||||||
: "auth_tokens 테이블에서 선택한 서비스의 최신 토큰을 사용합니다."}
|
: "auth_tokens 테이블에서 선택한 서비스의 최신 토큰을 사용합니다."}
|
||||||
|
|
@ -782,7 +782,7 @@ export default function BatchManagementNewPage() {
|
||||||
onChange={(e) => setDataArrayPath(e.target.value)}
|
onChange={(e) => setDataArrayPath(e.target.value)}
|
||||||
placeholder="response (예: data.items, results)"
|
placeholder="response (예: data.items, results)"
|
||||||
/>
|
/>
|
||||||
<p className="mt-1 text-xs text-gray-500">
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
API 응답에서 배열 데이터가 있는 경로를 입력하세요. 비워두면 응답 전체를 사용합니다.
|
API 응답에서 배열 데이터가 있는 경로를 입력하세요. 비워두면 응답 전체를 사용합니다.
|
||||||
<br />
|
<br />
|
||||||
예시: response, data.items, result.list
|
예시: response, data.items, result.list
|
||||||
|
|
@ -801,7 +801,7 @@ export default function BatchManagementNewPage() {
|
||||||
className="min-h-[100px]"
|
className="min-h-[100px]"
|
||||||
rows={5}
|
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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -809,7 +809,7 @@ export default function BatchManagementNewPage() {
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="border-t pt-4">
|
<div className="border-t pt-4">
|
||||||
<Label className="text-base font-medium">API 파라미터 설정</Label>
|
<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>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -868,26 +868,26 @@ export default function BatchManagementNewPage() {
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
{apiParamSource === "dynamic" && (
|
{apiParamSource === "dynamic" && (
|
||||||
<p className="mt-1 text-xs text-gray-500">
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
동적값은 배치 실행 시 설정된 값으로 치환됩니다. 예: {"{{user_id}}"} → 실제 사용자 ID
|
동적값은 배치 실행 시 설정된 값으로 치환됩니다. 예: {"{{user_id}}"} → 실제 사용자 ID
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{apiParamType === "url" && (
|
{apiParamType === "url" && (
|
||||||
<div className="rounded-lg bg-blue-50 p-3">
|
<div className="rounded-lg bg-primary/10 p-3">
|
||||||
<div className="text-sm font-medium text-blue-800">URL 파라미터 예시</div>
|
<div className="text-sm font-medium text-primary">URL 파라미터 예시</div>
|
||||||
<div className="mt-1 text-sm text-blue-700">
|
<div className="mt-1 text-sm text-primary">
|
||||||
엔드포인트: /api/users/{`{${apiParamName || "userId"}}`}
|
엔드포인트: /api/users/{`{${apiParamName || "userId"}}`}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{apiParamType === "query" && (
|
{apiParamType === "query" && (
|
||||||
<div className="rounded-lg bg-green-50 p-3">
|
<div className="rounded-lg bg-emerald-50 p-3">
|
||||||
<div className="text-sm font-medium text-green-800">쿼리 파라미터 예시</div>
|
<div className="text-sm font-medium text-emerald-800">쿼리 파라미터 예시</div>
|
||||||
<div className="mt-1 text-sm text-green-700">
|
<div className="mt-1 text-sm text-emerald-700">
|
||||||
실제 호출: {fromEndpoint || "/api/users"}?{apiParamName || "userId"}=
|
실제 호출: {fromEndpoint || "/api/users"}?{apiParamName || "userId"}=
|
||||||
{apiParamValue || "123"}
|
{apiParamValue || "123"}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -899,9 +899,9 @@ export default function BatchManagementNewPage() {
|
||||||
|
|
||||||
{/* API 호출 미리보기 정보 */}
|
{/* API 호출 미리보기 정보 */}
|
||||||
{fromApiUrl && fromEndpoint && (
|
{fromApiUrl && fromEndpoint && (
|
||||||
<div className="rounded-lg bg-gray-50 p-3">
|
<div className="rounded-lg bg-muted p-3">
|
||||||
<div className="text-sm font-medium text-gray-700">API 호출 정보</div>
|
<div className="text-sm font-medium text-foreground">API 호출 정보</div>
|
||||||
<div className="mt-1 text-sm text-gray-600">
|
<div className="mt-1 text-sm text-muted-foreground">
|
||||||
{fromApiMethod} {fromApiUrl}
|
{fromApiMethod} {fromApiUrl}
|
||||||
{apiParamType === "url" && apiParamName && apiParamValue
|
{apiParamType === "url" && apiParamName && apiParamValue
|
||||||
? fromEndpoint.replace(`{${apiParamName}}`, apiParamValue) || fromEndpoint + `/${apiParamValue}`
|
? fromEndpoint.replace(`{${apiParamName}}`, apiParamValue) || fromEndpoint + `/${apiParamValue}`
|
||||||
|
|
@ -911,14 +911,14 @@ export default function BatchManagementNewPage() {
|
||||||
: ""}
|
: ""}
|
||||||
</div>
|
</div>
|
||||||
{((authTokenMode === "direct" && fromApiKey) || (authTokenMode === "db" && authServiceName)) && (
|
{((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"
|
{authTokenMode === "direct"
|
||||||
? `Authorization: Bearer ${fromApiKey.substring(0, 15)}...`
|
? `Authorization: Bearer ${fromApiKey.substring(0, 15)}...`
|
||||||
: `Authorization: DB 토큰 (${authServiceName})`}
|
: `Authorization: DB 토큰 (${authServiceName})`}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{apiParamType !== "none" && apiParamName && apiParamValue && (
|
{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" ? "고정값" : "동적값"})
|
파라미터: {apiParamName} = {apiParamValue} ({apiParamSource === "static" ? "고정값" : "동적값"})
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -988,7 +988,7 @@ export default function BatchManagementNewPage() {
|
||||||
setSelectedColumns(selectedColumns.filter((col) => col !== column.column_name));
|
setSelectedColumns(selectedColumns.filter((col) => col !== column.column_name));
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className="rounded border-gray-300"
|
className="rounded border-input"
|
||||||
/>
|
/>
|
||||||
<label
|
<label
|
||||||
htmlFor={`col-${column.column_name}`}
|
htmlFor={`col-${column.column_name}`}
|
||||||
|
|
@ -1002,14 +1002,14 @@ export default function BatchManagementNewPage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 선택된 컬럼 개수 표시 */}
|
{/* 선택된 컬럼 개수 표시 */}
|
||||||
<div className="mt-2 text-xs text-gray-500">
|
<div className="mt-2 text-xs text-muted-foreground">
|
||||||
선택된 컬럼: {selectedColumns.length}개 / 전체: {fromColumns.length}개
|
선택된 컬럼: {selectedColumns.length}개 / 전체: {fromColumns.length}개
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 빠른 매핑 버튼들 */}
|
{/* 빠른 매핑 버튼들 */}
|
||||||
{selectedColumns.length > 0 && toApiFields.length > 0 && (
|
{selectedColumns.length > 0 && toApiFields.length > 0 && (
|
||||||
<div className="mt-3 rounded-lg border border-green-200 bg-green-50 p-3">
|
<div className="mt-3 rounded-lg border border-emerald-200 bg-emerald-50 p-3">
|
||||||
<div className="mb-2 text-sm font-medium text-green-800">빠른 매핑</div>
|
<div className="mb-2 text-sm font-medium text-emerald-800">빠른 매핑</div>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -1051,7 +1051,7 @@ export default function BatchManagementNewPage() {
|
||||||
setDbToApiFieldMapping(mapping);
|
setDbToApiFieldMapping(mapping);
|
||||||
toast.success(`${Object.keys(mapping).length}개 컬럼이 자동 매핑되었습니다.`);
|
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>
|
</button>
|
||||||
|
|
@ -1061,7 +1061,7 @@ export default function BatchManagementNewPage() {
|
||||||
setDbToApiFieldMapping({});
|
setDbToApiFieldMapping({});
|
||||||
toast.success("매핑이 초기화되었습니다.");
|
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>
|
</button>
|
||||||
|
|
@ -1071,9 +1071,9 @@ export default function BatchManagementNewPage() {
|
||||||
|
|
||||||
{/* 자동 생성된 JSON 미리보기 */}
|
{/* 자동 생성된 JSON 미리보기 */}
|
||||||
{selectedColumns.length > 0 && (
|
{selectedColumns.length > 0 && (
|
||||||
<div className="mt-3 rounded-lg border border-blue-200 bg-blue-50 p-3">
|
<div className="mt-3 rounded-lg border border-primary/20 bg-primary/10 p-3">
|
||||||
<div className="mb-2 text-sm font-medium text-blue-800">자동 생성된 JSON 구조</div>
|
<div className="mb-2 text-sm font-medium text-primary">자동 생성된 JSON 구조</div>
|
||||||
<pre className="overflow-x-auto font-mono text-xs text-blue-600">
|
<pre className="overflow-x-auto font-mono text-xs text-primary">
|
||||||
{JSON.stringify(
|
{JSON.stringify(
|
||||||
selectedColumns.reduce(
|
selectedColumns.reduce(
|
||||||
(obj, col) => {
|
(obj, col) => {
|
||||||
|
|
@ -1105,7 +1105,7 @@ export default function BatchManagementNewPage() {
|
||||||
setToApiBody(autoJson);
|
setToApiBody(autoJson);
|
||||||
toast.success("Request Body에 자동 생성된 JSON이 적용되었습니다.");
|
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에 적용
|
Request Body에 적용
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -1231,7 +1231,7 @@ export default function BatchManagementNewPage() {
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<p className="mt-1 text-xs text-gray-500">
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
이 컬럼 값이 같으면 UPDATE, 없으면 INSERT 합니다. (예: device_serial_number)
|
이 컬럼 값이 같으면 UPDATE, 없으면 INSERT 합니다. (예: device_serial_number)
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1273,7 +1273,7 @@ export default function BatchManagementNewPage() {
|
||||||
placeholder="/api/users"
|
placeholder="/api/users"
|
||||||
/>
|
/>
|
||||||
{(toApiMethod === "PUT" || toApiMethod === "DELETE") && (
|
{(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}"}
|
실제 URL: {toEndpoint}/{urlPathColumn ? `{${urlPathColumn}}` : "{ID}"}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
@ -1310,7 +1310,7 @@ export default function BatchManagementNewPage() {
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</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)
|
PUT/DELETE 요청 시 URL 경로에 포함될 컬럼을 선택하세요. (예: USER_ID → /api/users/user123)
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1321,7 +1321,7 @@ export default function BatchManagementNewPage() {
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={previewToApiData}
|
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" />
|
<Eye className="h-4 w-4" />
|
||||||
<span>API 필드 미리보기</span>
|
<span>API 필드 미리보기</span>
|
||||||
|
|
@ -1330,13 +1330,13 @@ export default function BatchManagementNewPage() {
|
||||||
|
|
||||||
{/* TO API 필드 표시 */}
|
{/* TO API 필드 표시 */}
|
||||||
{toApiFields.length > 0 && (
|
{toApiFields.length > 0 && (
|
||||||
<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="mb-2 text-sm font-medium text-green-800">
|
<div className="mb-2 text-sm font-medium text-emerald-800">
|
||||||
API 필드 목록 ({toApiFields.length}개)
|
API 필드 목록 ({toApiFields.length}개)
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{toApiFields.map((field) => (
|
{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}
|
{field}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
|
|
@ -1355,7 +1355,7 @@ export default function BatchManagementNewPage() {
|
||||||
placeholder='{"id": "{{id}}", "name": "{{name}}", "email": "{{email}}"}'
|
placeholder='{"id": "{{id}}", "name": "{{name}}", "email": "{{email}}"}'
|
||||||
className="h-24 w-full rounded-md border p-2 font-mono text-sm"
|
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}}"}
|
DB 컬럼 값을 {"{{컬럼명}}"} 형태로 매핑하세요. 예: {"{{user_id}}, {{user_name}}"}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1363,15 +1363,15 @@ export default function BatchManagementNewPage() {
|
||||||
|
|
||||||
{/* API 호출 정보 */}
|
{/* API 호출 정보 */}
|
||||||
{toApiUrl && toApiKey && toEndpoint && (
|
{toApiUrl && toApiKey && toEndpoint && (
|
||||||
<div className="rounded-lg bg-gray-50 p-3">
|
<div className="rounded-lg bg-muted p-3">
|
||||||
<div className="text-sm font-medium text-gray-700">API 호출 정보</div>
|
<div className="text-sm font-medium text-foreground">API 호출 정보</div>
|
||||||
<div className="mt-1 text-sm text-gray-600">
|
<div className="mt-1 text-sm text-muted-foreground">
|
||||||
{toApiMethod} {toApiUrl}
|
{toApiMethod} {toApiUrl}
|
||||||
{toEndpoint}
|
{toEndpoint}
|
||||||
</div>
|
</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 && (
|
{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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -1394,7 +1394,7 @@ export default function BatchManagementNewPage() {
|
||||||
데이터 불러오고 매핑하기
|
데이터 불러오고 매핑하기
|
||||||
</Button>
|
</Button>
|
||||||
{(!fromApiUrl || !fromEndpoint || !toTable) && (
|
{(!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 섹션의 필수 값을 모두 입력해야 합니다.
|
FROM 섹션과 TO 섹션의 필수 값을 모두 입력해야 합니다.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
@ -1693,17 +1693,17 @@ const DbToRestApiMappingCard = memo(function DbToRestApiMappingCard({
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="max-h-96 space-y-3 overflow-y-auto rounded-lg border p-4">
|
<div className="max-h-96 space-y-3 overflow-y-auto rounded-lg border p-4">
|
||||||
{selectedColumnObjects.map((column) => (
|
{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 컬럼 정보 */}
|
{/* DB 컬럼 정보 */}
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="text-sm font-medium">{column.column_name}</div>
|
<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"}
|
타입: {column.data_type} | NULL: {column.is_nullable ? "Y" : "N"}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 화살표 */}
|
{/* 화살표 */}
|
||||||
<div className="text-gray-400">→</div>
|
<div className="text-muted-foreground/70">→</div>
|
||||||
|
|
||||||
{/* API 필드 선택 드롭다운 */}
|
{/* API 필드 선택 드롭다운 */}
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
|
|
@ -1735,7 +1735,7 @@ const DbToRestApiMappingCard = memo(function DbToRestApiMappingCard({
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="API 필드명을 직접 입력하세요"
|
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) => {
|
onChange={(e) => {
|
||||||
setDbToApiFieldMapping((prev) => ({
|
setDbToApiFieldMapping((prev) => ({
|
||||||
...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]
|
{dbToApiFieldMapping[column.column_name]
|
||||||
? `매핑: ${column.column_name} → ${dbToApiFieldMapping[column.column_name]}`
|
? `매핑: ${column.column_name} → ${dbToApiFieldMapping[column.column_name]}`
|
||||||
: `기본값: ${column.column_name} (DB 컬럼명 사용)`}
|
: `기본값: ${column.column_name} (DB 컬럼명 사용)`}
|
||||||
|
|
@ -1755,32 +1755,32 @@ const DbToRestApiMappingCard = memo(function DbToRestApiMappingCard({
|
||||||
{/* 템플릿 미리보기 */}
|
{/* 템플릿 미리보기 */}
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="rounded border bg-white p-2 font-mono text-sm">{`{{${column.column_name}}}`}</div>
|
<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>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{selectedColumns.length > 0 && (
|
{selectedColumns.length > 0 && (
|
||||||
<div className="mt-4 rounded-lg border border-blue-200 bg-blue-50 p-3">
|
<div className="mt-4 rounded-lg border border-primary/20 bg-primary/10 p-3">
|
||||||
<div className="text-sm font-medium text-blue-800">자동 생성된 JSON 구조</div>
|
<div className="text-sm font-medium text-primary">자동 생성된 JSON 구조</div>
|
||||||
<pre className="mt-1 overflow-x-auto font-mono text-xs text-blue-600">{autoJsonPreview}</pre>
|
<pre className="mt-1 overflow-x-auto font-mono text-xs text-primary">{autoJsonPreview}</pre>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setToApiBody(autoJsonPreview);
|
setToApiBody(autoJsonPreview);
|
||||||
toast.success("Request Body에 자동 생성된 JSON이 적용되었습니다.");
|
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에 적용
|
Request Body에 적용
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="mt-4 rounded-lg border border-blue-200 bg-blue-50 p-3">
|
<div className="mt-4 rounded-lg border border-primary/20 bg-primary/10 p-3">
|
||||||
<div className="text-sm font-medium text-blue-800">매핑 사용 예시</div>
|
<div className="text-sm font-medium text-primary">매핑 사용 예시</div>
|
||||||
<div className="mt-1 font-mono text-xs text-blue-600">
|
<div className="mt-1 font-mono text-xs text-primary">
|
||||||
{'{"id": "{{id}}", "name": "{{user_name}}", "email": "{{email}}"}'}
|
{'{"id": "{{id}}", "name": "{{user_name}}", "email": "{{email}}"}'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -13,14 +13,6 @@ import {
|
||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import {
|
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableHead,
|
|
||||||
TableHeader,
|
|
||||||
TableRow,
|
|
||||||
} from "@/components/ui/table";
|
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
|
|
@ -43,6 +35,8 @@ import {
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { BatchAPI, BatchJob } from "@/lib/api/batch";
|
import { BatchAPI, BatchJob } from "@/lib/api/batch";
|
||||||
import BatchJobModal from "@/components/admin/BatchJobModal";
|
import BatchJobModal from "@/components/admin/BatchJobModal";
|
||||||
|
import { showErrorToast } from "@/lib/utils/toastUtils";
|
||||||
|
import { ResponsiveDataView, RDVColumn, RDVCardField } from "@/components/common/ResponsiveDataView";
|
||||||
|
|
||||||
export default function BatchManagementPage() {
|
export default function BatchManagementPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
@ -54,7 +48,6 @@ export default function BatchManagementPage() {
|
||||||
const [typeFilter, setTypeFilter] = useState("all");
|
const [typeFilter, setTypeFilter] = useState("all");
|
||||||
const [jobTypes, setJobTypes] = useState<Array<{ value: string; label: string }>>([]);
|
const [jobTypes, setJobTypes] = useState<Array<{ value: string; label: string }>>([]);
|
||||||
|
|
||||||
// 모달 상태
|
|
||||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
const [selectedJob, setSelectedJob] = useState<BatchJob | null>(null);
|
const [selectedJob, setSelectedJob] = useState<BatchJob | null>(null);
|
||||||
const [isBatchTypeModalOpen, setIsBatchTypeModalOpen] = useState(false);
|
const [isBatchTypeModalOpen, setIsBatchTypeModalOpen] = useState(false);
|
||||||
|
|
@ -84,7 +77,7 @@ export default function BatchManagementPage() {
|
||||||
const loadJobTypes = async () => {
|
const loadJobTypes = async () => {
|
||||||
try {
|
try {
|
||||||
const types = await BatchAPI.getSupportedJobTypes();
|
const types = await BatchAPI.getSupportedJobTypes();
|
||||||
setJobTypes(types);
|
setJobTypes(types.map(t => ({ value: t, label: t })));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("작업 타입 조회 오류:", error);
|
console.error("작업 타입 조회 오류:", error);
|
||||||
}
|
}
|
||||||
|
|
@ -93,7 +86,6 @@ export default function BatchManagementPage() {
|
||||||
const filterJobs = () => {
|
const filterJobs = () => {
|
||||||
let filtered = jobs;
|
let filtered = jobs;
|
||||||
|
|
||||||
// 검색어 필터
|
|
||||||
if (searchTerm) {
|
if (searchTerm) {
|
||||||
filtered = filtered.filter(job =>
|
filtered = filtered.filter(job =>
|
||||||
job.job_name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
job.job_name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
|
|
@ -101,12 +93,10 @@ export default function BatchManagementPage() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 상태 필터
|
|
||||||
if (statusFilter !== "all") {
|
if (statusFilter !== "all") {
|
||||||
filtered = filtered.filter(job => job.is_active === statusFilter);
|
filtered = filtered.filter(job => job.is_active === statusFilter);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 타입 필터
|
|
||||||
if (typeFilter !== "all") {
|
if (typeFilter !== "all") {
|
||||||
filtered = filtered.filter(job => job.job_type === typeFilter);
|
filtered = filtered.filter(job => job.job_type === typeFilter);
|
||||||
}
|
}
|
||||||
|
|
@ -123,12 +113,10 @@ export default function BatchManagementPage() {
|
||||||
setIsBatchTypeModalOpen(false);
|
setIsBatchTypeModalOpen(false);
|
||||||
|
|
||||||
if (type === 'db-to-db') {
|
if (type === 'db-to-db') {
|
||||||
// 기존 배치 생성 모달 열기
|
|
||||||
console.log("DB → DB 배치 모달 열기");
|
console.log("DB → DB 배치 모달 열기");
|
||||||
setSelectedJob(null);
|
setSelectedJob(null);
|
||||||
setIsModalOpen(true);
|
setIsModalOpen(true);
|
||||||
} else if (type === 'restapi-to-db') {
|
} else if (type === 'restapi-to-db') {
|
||||||
// 새로운 REST API 배치 페이지로 이동
|
|
||||||
console.log("REST API → DB 페이지로 이동:", '/admin/batch-management-new');
|
console.log("REST API → DB 페이지로 이동:", '/admin/batch-management-new');
|
||||||
router.push('/admin/batch-management-new');
|
router.push('/admin/batch-management-new');
|
||||||
}
|
}
|
||||||
|
|
@ -170,41 +158,167 @@ export default function BatchManagementPage() {
|
||||||
|
|
||||||
const getStatusBadge = (isActive: string) => {
|
const getStatusBadge = (isActive: string) => {
|
||||||
return isActive === "Y" ? (
|
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 getTypeBadge = (type: string) => {
|
||||||
const option = jobTypes.find(opt => opt.value === type);
|
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 (
|
return (
|
||||||
<Badge className={colors[type as keyof typeof colors] || "bg-gray-100 text-gray-800"}>
|
<Badge variant="outline">{option?.label || type}</Badge>
|
||||||
<span className="mr-1">{icons[type as keyof typeof icons] || "📋"}</span>
|
|
||||||
{option?.label || type}
|
|
||||||
</Badge>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const getSuccessRate = (job: BatchJob) => {
|
const getSuccessRate = (job: BatchJob) => {
|
||||||
if (job.execution_count === 0) return 100;
|
if (!job.execution_count) return 100;
|
||||||
return Math.round((job.success_count / job.execution_count) * 100);
|
return Math.round(((job.success_count ?? 0) / job.execution_count) * 100);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getSuccessRateColor = (rate: number) => {
|
||||||
|
if (rate >= 90) return 'text-success';
|
||||||
|
if (rate >= 70) return 'text-warning';
|
||||||
|
return 'text-destructive';
|
||||||
|
};
|
||||||
|
|
||||||
|
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-xs text-muted-foreground">{job.description}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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-xs text-muted-foreground">
|
||||||
|
성공 {job.success_count} / 실패 {job.failure_count}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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()
|
||||||
|
: "-"}
|
||||||
|
</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">
|
||||||
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem onClick={() => handleEdit(job)}>
|
||||||
|
<Edit className="h-4 w-4 mr-2" />
|
||||||
|
수정
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => handleExecute(job)}
|
||||||
|
disabled={job.is_active !== "Y"}
|
||||||
|
>
|
||||||
|
<Play className="h-4 w-4 mr-2" />
|
||||||
|
실행
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => handleDelete(job)}>
|
||||||
|
<Trash2 className="h-4 w-4 mr-2" />
|
||||||
|
삭제
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* 헤더 */}
|
{/* 헤더 */}
|
||||||
|
|
@ -228,11 +342,10 @@ export default function BatchManagementPage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 통계 카드 */}
|
{/* 통계 카드 */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
<div className="grid grid-cols-2 gap-4 lg:grid-cols-4">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
<CardTitle className="text-sm font-medium">총 작업</CardTitle>
|
<CardTitle className="text-sm font-medium">총 작업</CardTitle>
|
||||||
<div className="text-2xl">📋</div>
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="text-2xl font-bold">{jobs.length}</div>
|
<div className="text-2xl font-bold">{jobs.length}</div>
|
||||||
|
|
@ -245,11 +358,10 @@ export default function BatchManagementPage() {
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
<CardTitle className="text-sm font-medium">총 실행</CardTitle>
|
<CardTitle className="text-sm font-medium">총 실행</CardTitle>
|
||||||
<div className="text-2xl">▶️</div>
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="text-2xl font-bold">
|
<div className="text-2xl font-bold">
|
||||||
{jobs.reduce((sum, job) => sum + job.execution_count, 0)}
|
{jobs.reduce((sum, job) => sum + (job.execution_count ?? 0), 0)}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground">누적 실행 횟수</p>
|
<p className="text-xs text-muted-foreground">누적 실행 횟수</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|
@ -258,11 +370,10 @@ export default function BatchManagementPage() {
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
<CardTitle className="text-sm font-medium">성공</CardTitle>
|
<CardTitle className="text-sm font-medium">성공</CardTitle>
|
||||||
<div className="text-2xl">✅</div>
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="text-2xl font-bold text-green-600">
|
<div className="text-2xl font-bold text-success">
|
||||||
{jobs.reduce((sum, job) => sum + job.success_count, 0)}
|
{jobs.reduce((sum, job) => sum + (job.success_count ?? 0), 0)}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground">총 성공 횟수</p>
|
<p className="text-xs text-muted-foreground">총 성공 횟수</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|
@ -271,11 +382,10 @@ export default function BatchManagementPage() {
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
<CardTitle className="text-sm font-medium">실패</CardTitle>
|
<CardTitle className="text-sm font-medium">실패</CardTitle>
|
||||||
<div className="text-2xl">❌</div>
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="text-2xl font-bold text-red-600">
|
<div className="text-2xl font-bold text-destructive">
|
||||||
{jobs.reduce((sum, job) => sum + job.failure_count, 0)}
|
{jobs.reduce((sum, job) => sum + (job.failure_count ?? 0), 0)}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground">총 실패 횟수</p>
|
<p className="text-xs text-muted-foreground">총 실패 횟수</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|
@ -283,203 +393,109 @@ export default function BatchManagementPage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 필터 및 검색 */}
|
{/* 필터 및 검색 */}
|
||||||
<Card>
|
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||||
<CardHeader>
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||||
<CardTitle>필터 및 검색</CardTitle>
|
<div className="relative w-full sm:w-[300px]">
|
||||||
</CardHeader>
|
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
<CardContent>
|
<Input
|
||||||
<div className="flex flex-col md:flex-row gap-4">
|
placeholder="작업명, 설명으로 검색..."
|
||||||
<div className="flex-1">
|
value={searchTerm}
|
||||||
<div className="relative">
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
<Search className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
|
className="h-10 pl-10 text-sm"
|
||||||
<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>
|
</div>
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* 배치 작업 목록 */}
|
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||||
<Card>
|
<SelectTrigger className="h-10 w-full sm:w-[160px]">
|
||||||
<CardHeader>
|
<SelectValue placeholder="상태" />
|
||||||
<CardTitle>배치 작업 목록 ({filteredJobs.length}개)</CardTitle>
|
</SelectTrigger>
|
||||||
</CardHeader>
|
<SelectContent>
|
||||||
<CardContent>
|
<SelectItem value="all">전체</SelectItem>
|
||||||
{isLoading ? (
|
<SelectItem value="Y">활성</SelectItem>
|
||||||
<div className="text-center py-8">
|
<SelectItem value="N">비활성</SelectItem>
|
||||||
<RefreshCw className="h-8 w-8 animate-spin mx-auto mb-2" />
|
</SelectContent>
|
||||||
<p>배치 작업을 불러오는 중...</p>
|
</Select>
|
||||||
</div>
|
|
||||||
) : filteredJobs.length === 0 ? (
|
<Select value={typeFilter} onValueChange={setTypeFilter}>
|
||||||
<div className="text-center py-8 text-muted-foreground">
|
<SelectTrigger className="h-10 w-full sm:w-[160px]">
|
||||||
{jobs.length === 0 ? "배치 작업이 없습니다." : "검색 결과가 없습니다."}
|
<SelectValue placeholder="작업 타입" />
|
||||||
</div>
|
</SelectTrigger>
|
||||||
) : (
|
<SelectContent>
|
||||||
<Table>
|
<SelectItem value="all">전체 타입</SelectItem>
|
||||||
<TableHeader>
|
{jobTypes.map((type) => (
|
||||||
<TableRow>
|
<SelectItem key={type.value} value={type.value}>
|
||||||
<TableHead className="h-12 px-6 py-3 text-sm font-semibold">작업명</TableHead>
|
{type.label}
|
||||||
<TableHead className="h-12 px-6 py-3 text-sm font-semibold">타입</TableHead>
|
</SelectItem>
|
||||||
<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>
|
</SelectContent>
|
||||||
<TableHead className="h-12 px-6 py-3 text-sm font-semibold">실행 통계</TableHead>
|
</Select>
|
||||||
<TableHead className="h-12 px-6 py-3 text-sm font-semibold">성공률</TableHead>
|
</div>
|
||||||
<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>
|
<Button variant="outline" onClick={loadJobs} disabled={isLoading} className="h-10 w-full sm:w-auto">
|
||||||
</TableRow>
|
<RefreshCw className={`mr-2 h-4 w-4 ${isLoading ? 'animate-spin' : ''}`} />
|
||||||
</TableHeader>
|
새로고침
|
||||||
<TableBody>
|
</Button>
|
||||||
{filteredJobs.map((job) => (
|
</div>
|
||||||
<TableRow key={job.id}>
|
|
||||||
<TableCell>
|
{/* 배치 작업 목록 제목 */}
|
||||||
<div>
|
<div className="text-sm text-muted-foreground">
|
||||||
<div className="font-medium">{job.job_name}</div>
|
총 <span className="font-semibold text-foreground">{filteredJobs.length}</span>개
|
||||||
{job.description && (
|
</div>
|
||||||
<div className="text-sm text-muted-foreground">
|
|
||||||
{job.description}
|
<ResponsiveDataView<BatchJob>
|
||||||
</div>
|
data={filteredJobs}
|
||||||
)}
|
columns={columns}
|
||||||
</div>
|
keyExtractor={(job) => String(job.id)}
|
||||||
</TableCell>
|
isLoading={isLoading}
|
||||||
<TableCell>
|
emptyMessage={jobs.length === 0 ? "배치 작업이 없습니다." : "검색 결과가 없습니다."}
|
||||||
{getTypeBadge(job.job_type)}
|
skeletonCount={5}
|
||||||
</TableCell>
|
cardTitle={(job) => job.job_name}
|
||||||
<TableCell className="font-mono text-sm">
|
cardSubtitle={(job) => job.description ? (
|
||||||
{job.schedule_cron || "-"}
|
<span className="truncate text-sm text-muted-foreground">{job.description}</span>
|
||||||
</TableCell>
|
) : undefined}
|
||||||
<TableCell>
|
cardHeaderRight={(job) => getStatusBadge(job.is_active)}
|
||||||
{getStatusBadge(job.is_active)}
|
cardFields={cardFields}
|
||||||
</TableCell>
|
actionsLabel="작업"
|
||||||
<TableCell>
|
actionsWidth="80px"
|
||||||
<div className="text-sm">
|
renderActions={renderDropdownActions}
|
||||||
<div>총 {job.execution_count}회</div>
|
/>
|
||||||
<div className="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>
|
|
||||||
{job.last_executed_at
|
|
||||||
? new Date(job.last_executed_at).toLocaleString()
|
|
||||||
: "-"}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button variant="ghost" className="h-8 w-8 p-0">
|
|
||||||
<MoreHorizontal className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end">
|
|
||||||
<DropdownMenuItem onClick={() => handleEdit(job)}>
|
|
||||||
<Edit className="h-4 w-4 mr-2" />
|
|
||||||
수정
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={() => handleExecute(job)}
|
|
||||||
disabled={job.is_active !== "Y"}
|
|
||||||
>
|
|
||||||
<Play className="h-4 w-4 mr-2" />
|
|
||||||
실행
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem onClick={() => handleDelete(job)}>
|
|
||||||
<Trash2 className="h-4 w-4 mr-2" />
|
|
||||||
삭제
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* 배치 타입 선택 모달 */}
|
{/* 배치 타입 선택 모달 */}
|
||||||
{isBatchTypeModalOpen && (
|
{isBatchTypeModalOpen && (
|
||||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||||
<Card className="w-full max-w-2xl mx-4">
|
<Card className="mx-4 w-full max-w-2xl">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-center">배치 타입 선택</CardTitle>
|
<CardTitle className="text-center">배치 타입 선택</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
{/* DB → DB */}
|
|
||||||
<div
|
<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')}
|
onClick={() => handleBatchTypeSelect('db-to-db')}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-center mb-4">
|
<div className="mb-4 flex items-center justify-center">
|
||||||
<Database className="w-8 h-8 text-blue-600 mr-2" />
|
<Database className="mr-2 h-8 w-8 text-primary" />
|
||||||
<ArrowRight className="w-6 h-6 text-gray-400 mr-2" />
|
<ArrowRight className="mr-2 h-6 w-6 text-muted-foreground" />
|
||||||
<Database className="w-8 h-8 text-blue-600" />
|
<Database className="h-8 w-8 text-primary" />
|
||||||
</div>
|
</div>
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="font-medium text-lg mb-2">DB → DB</div>
|
<div className="mb-2 text-lg font-medium">DB → DB</div>
|
||||||
<div className="text-sm text-gray-500">데이터베이스 간 데이터 동기화</div>
|
<div className="text-sm text-muted-foreground">데이터베이스 간 데이터 동기화</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* REST API → DB */}
|
|
||||||
<div
|
<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')}
|
onClick={() => handleBatchTypeSelect('restapi-to-db')}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-center mb-4">
|
<div className="mb-4 flex items-center justify-center">
|
||||||
<Globe className="w-8 h-8 text-green-600 mr-2" />
|
<Globe className="mr-2 h-8 w-8 text-success" />
|
||||||
<ArrowRight className="w-6 h-6 text-gray-400 mr-2" />
|
<ArrowRight className="mr-2 h-6 w-6 text-muted-foreground" />
|
||||||
<Database className="w-8 h-8 text-green-600" />
|
<Database className="h-8 w-8 text-success" />
|
||||||
</div>
|
</div>
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="font-medium text-lg mb-2">REST API → DB</div>
|
<div className="mb-2 text-lg font-medium">REST API → DB</div>
|
||||||
<div className="text-sm text-gray-500">REST API에서 데이터베이스로 데이터 수집</div>
|
<div className="text-sm text-muted-foreground">REST API에서 데이터베이스로 데이터 수집</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -381,7 +381,7 @@ export default function AutoFillTab() {
|
||||||
<Pencil className="h-4 w-4" />
|
<Pencil className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="ghost" size="icon" onClick={() => handleDeleteConfirm(group.groupCode)}>
|
<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>
|
</Button>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
|
|
@ -676,7 +676,7 @@ export default function AutoFillTab() {
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
<AlertDialogCancel>취소</AlertDialogCancel>
|
<AlertDialogCancel>취소</AlertDialogCancel>
|
||||||
<AlertDialogAction onClick={handleDelete} className="bg-red-500 hover:bg-red-600">
|
<AlertDialogAction onClick={handleDelete} className="bg-destructive hover:bg-destructive">
|
||||||
삭제
|
삭제
|
||||||
</AlertDialogAction>
|
</AlertDialogAction>
|
||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
|
|
|
||||||
|
|
@ -577,9 +577,9 @@ export default function CascadingRelationsTab() {
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="flex items-center gap-2 text-sm">
|
<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" />
|
<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}
|
{relation.child_table}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -619,7 +619,7 @@ export default function CascadingRelationsTab() {
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* Step 1: 부모 테이블 */}
|
{/* Step 1: 부모 테이블 */}
|
||||||
<div className="rounded-lg border p-4">
|
<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="grid grid-cols-2 gap-3">
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label className="text-xs">테이블</Label>
|
<Label className="text-xs">테이블</Label>
|
||||||
|
|
@ -696,7 +696,7 @@ export default function CascadingRelationsTab() {
|
||||||
|
|
||||||
{/* Step 2: 자식 테이블 */}
|
{/* Step 2: 자식 테이블 */}
|
||||||
<div className="rounded-lg border p-4">
|
<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="grid grid-cols-2 gap-3">
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label className="text-xs">테이블</Label>
|
<Label className="text-xs">테이블</Label>
|
||||||
|
|
|
||||||
|
|
@ -304,7 +304,7 @@ export default function ConditionTab() {
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="text-sm">
|
<div className="text-sm">
|
||||||
<span className="text-muted-foreground">{condition.conditionField}</span>
|
<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>
|
<span className="font-medium">{condition.conditionValue}</span>
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|
@ -329,7 +329,7 @@ export default function ConditionTab() {
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={() => handleDeleteConfirm(condition.conditionId!)}
|
onClick={() => handleDeleteConfirm(condition.conditionId!)}
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4 text-red-500" />
|
<Trash2 className="h-4 w-4 text-destructive" />
|
||||||
</Button>
|
</Button>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
|
|
@ -491,7 +491,7 @@ export default function ConditionTab() {
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
<AlertDialogCancel>취소</AlertDialogCancel>
|
<AlertDialogCancel>취소</AlertDialogCancel>
|
||||||
<AlertDialogAction onClick={handleDelete} className="bg-red-500 hover:bg-red-600">
|
<AlertDialogAction onClick={handleDelete} className="bg-destructive hover:bg-destructive">
|
||||||
삭제
|
삭제
|
||||||
</AlertDialogAction>
|
</AlertDialogAction>
|
||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
|
|
|
||||||
|
|
@ -501,7 +501,7 @@ export default function HierarchyTab() {
|
||||||
<Pencil className="h-4 w-4" />
|
<Pencil className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="ghost" size="icon" onClick={() => handleDeleteConfirm(group.groupCode)}>
|
<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>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -548,7 +548,7 @@ export default function HierarchyTab() {
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={() => handleDeleteLevel(level.levelId!, group.groupCode)}
|
onClick={() => handleDeleteLevel(level.levelId!, group.groupCode)}
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4 text-red-500" />
|
<Trash2 className="h-4 w-4 text-destructive" />
|
||||||
</Button>
|
</Button>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
|
|
@ -836,7 +836,7 @@ export default function HierarchyTab() {
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
<AlertDialogCancel>취소</AlertDialogCancel>
|
<AlertDialogCancel>취소</AlertDialogCancel>
|
||||||
<AlertDialogAction onClick={handleDelete} className="bg-red-500 hover:bg-red-600">
|
<AlertDialogAction onClick={handleDelete} className="bg-destructive hover:bg-destructive">
|
||||||
삭제
|
삭제
|
||||||
</AlertDialogAction>
|
</AlertDialogAction>
|
||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
|
|
|
||||||
|
|
@ -354,7 +354,7 @@ export default function MutualExclusionTab() {
|
||||||
<Pencil className="h-4 w-4" />
|
<Pencil className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="ghost" size="icon" onClick={() => handleDeleteConfirm(exclusion.exclusionId!)}>
|
<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>
|
</Button>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
|
|
@ -404,7 +404,7 @@ export default function MutualExclusionTab() {
|
||||||
/>
|
/>
|
||||||
{fieldList.length > 2 && (
|
{fieldList.length > 2 && (
|
||||||
<Button variant="ghost" size="icon" onClick={() => removeField(index)}>
|
<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>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -571,7 +571,7 @@ export default function MutualExclusionTab() {
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
<AlertDialogCancel>취소</AlertDialogCancel>
|
<AlertDialogCancel>취소</AlertDialogCancel>
|
||||||
<AlertDialogAction onClick={handleDelete} className="bg-red-500 hover:bg-red-600">
|
<AlertDialogAction onClick={handleDelete} className="bg-destructive hover:bg-destructive">
|
||||||
삭제
|
삭제
|
||||||
</AlertDialogAction>
|
</AlertDialogAction>
|
||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
|
|
|
||||||
|
|
@ -133,7 +133,7 @@ export default function DebugLayoutPage() {
|
||||||
<h1 className="mb-4 text-2xl font-bold">관리자 레이아웃 디버깅</h1>
|
<h1 className="mb-4 text-2xl font-bold">관리자 레이아웃 디버깅</h1>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<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>
|
<h2 className="mb-2 font-semibold">토큰 상태</h2>
|
||||||
<p>토큰 존재: {debugInfo.hasToken ? "✅ 예" : "❌ 아니오"}</p>
|
<p>토큰 존재: {debugInfo.hasToken ? "✅ 예" : "❌ 아니오"}</p>
|
||||||
<p>토큰 길이: {debugInfo.tokenLength}</p>
|
<p>토큰 길이: {debugInfo.tokenLength}</p>
|
||||||
|
|
@ -142,14 +142,14 @@ export default function DebugLayoutPage() {
|
||||||
<p>SessionStorage 토큰: {debugInfo.sessionToken ? "✅ 존재" : "❌ 없음"}</p>
|
<p>SessionStorage 토큰: {debugInfo.sessionToken ? "✅ 존재" : "❌ 없음"}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded bg-blue-100 p-4">
|
<div className="rounded bg-primary/10 p-4">
|
||||||
<h2 className="mb-2 font-semibold">페이지 정보</h2>
|
<h2 className="mb-2 font-semibold">페이지 정보</h2>
|
||||||
<p>현재 URL: {debugInfo.currentUrl}</p>
|
<p>현재 URL: {debugInfo.currentUrl}</p>
|
||||||
<p>Pathname: {debugInfo.pathname}</p>
|
<p>Pathname: {debugInfo.pathname}</p>
|
||||||
<p>시간: {debugInfo.timestamp}</p>
|
<p>시간: {debugInfo.timestamp}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded bg-yellow-100 p-4">
|
<div className="rounded bg-amber-100 p-4">
|
||||||
<h2 className="mb-2 font-semibold">토큰 관리</h2>
|
<h2 className="mb-2 font-semibold">토큰 관리</h2>
|
||||||
<div className="space-x-2">
|
<div className="space-x-2">
|
||||||
<button
|
<button
|
||||||
|
|
@ -157,11 +157,11 @@ export default function DebugLayoutPage() {
|
||||||
const token = localStorage.getItem("authToken");
|
const token = localStorage.getItem("authToken");
|
||||||
alert(`토큰: ${token ? "존재" : "없음"}\n길이: ${token ? token.length : 0}`);
|
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>
|
||||||
<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>
|
||||||
<button
|
<button
|
||||||
|
|
@ -173,13 +173,13 @@ export default function DebugLayoutPage() {
|
||||||
</div>
|
</div>
|
||||||
</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>
|
<h2 className="mb-2 font-semibold">API 테스트</h2>
|
||||||
<div className="mb-4 space-x-2">
|
<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 테스트
|
인증 상태 API 테스트
|
||||||
</button>
|
</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 테스트
|
사용자 API 테스트
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
|
|
@ -194,7 +194,7 @@ export default function DebugLayoutPage() {
|
||||||
<div
|
<div
|
||||||
className={`rounded p-3 ${
|
className={`rounded p-3 ${
|
||||||
apiTestResult.status === "success"
|
apiTestResult.status === "success"
|
||||||
? "bg-green-200"
|
? "bg-emerald-200"
|
||||||
: apiTestResult.status === "error"
|
: apiTestResult.status === "error"
|
||||||
? "bg-red-200"
|
? "bg-red-200"
|
||||||
: "bg-yellow-200"
|
: "bg-yellow-200"
|
||||||
|
|
|
||||||
|
|
@ -28,27 +28,27 @@ export default function SimpleDebugPage() {
|
||||||
<h1 className="mb-4 text-2xl font-bold">간단한 토큰 디버깅</h1>
|
<h1 className="mb-4 text-2xl font-bold">간단한 토큰 디버깅</h1>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<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>
|
<h2 className="mb-2 font-semibold">토큰 상태</h2>
|
||||||
<p>토큰 존재: {tokenInfo.hasToken ? "✅ 예" : "❌ 아니오"}</p>
|
<p>토큰 존재: {tokenInfo.hasToken ? "✅ 예" : "❌ 아니오"}</p>
|
||||||
<p>토큰 길이: {tokenInfo.tokenLength}</p>
|
<p>토큰 길이: {tokenInfo.tokenLength}</p>
|
||||||
<p>토큰 시작: {tokenInfo.tokenStart}</p>
|
<p>토큰 시작: {tokenInfo.tokenStart}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded bg-blue-100 p-4">
|
<div className="rounded bg-primary/10 p-4">
|
||||||
<h2 className="mb-2 font-semibold">페이지 정보</h2>
|
<h2 className="mb-2 font-semibold">페이지 정보</h2>
|
||||||
<p>현재 URL: {tokenInfo.currentUrl}</p>
|
<p>현재 URL: {tokenInfo.currentUrl}</p>
|
||||||
<p>시간: {tokenInfo.timestamp}</p>
|
<p>시간: {tokenInfo.timestamp}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded bg-yellow-100 p-4">
|
<div className="rounded bg-amber-100 p-4">
|
||||||
<h2 className="mb-2 font-semibold">테스트 버튼</h2>
|
<h2 className="mb-2 font-semibold">테스트 버튼</h2>
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const token = localStorage.getItem("authToken");
|
const token = localStorage.getItem("authToken");
|
||||||
alert(`토큰: ${token ? "존재" : "없음"}\n길이: ${token ? token.length : 0}`);
|
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>
|
||||||
|
|
|
||||||
|
|
@ -15,16 +15,16 @@ export default function AdminDebugPage() {
|
||||||
<h1 className="mb-4 text-2xl font-bold">어드민 권한 디버깅</h1>
|
<h1 className="mb-4 text-2xl font-bold">어드민 권한 디버깅</h1>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<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>
|
<h2 className="mb-2 font-semibold">인증 상태</h2>
|
||||||
<p>로딩: {loading ? "예" : "아니오"}</p>
|
<p>로딩: {loading ? "예" : "아니오"}</p>
|
||||||
<p>로그인: {isLoggedIn ? "예" : "아니오"}</p>
|
<p>로그인: {isLoggedIn ? "예" : "아니오"}</p>
|
||||||
<p>관리자: {isAdmin ? "예" : "아니오"}</p>
|
<p>관리자: {isAdmin ? "예" : "아니오"}</p>
|
||||||
{error && <p className="text-red-500">에러: {error}</p>}
|
{error && <p className="text-destructive">에러: {error}</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{user && (
|
{user && (
|
||||||
<div className="rounded bg-blue-100 p-4">
|
<div className="rounded bg-primary/10 p-4">
|
||||||
<h2 className="mb-2 font-semibold">사용자 정보</h2>
|
<h2 className="mb-2 font-semibold">사용자 정보</h2>
|
||||||
<p>ID: {user.userId}</p>
|
<p>ID: {user.userId}</p>
|
||||||
<p>이름: {user.userName}</p>
|
<p>이름: {user.userName}</p>
|
||||||
|
|
@ -34,7 +34,7 @@ export default function AdminDebugPage() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="rounded bg-green-100 p-4">
|
<div className="rounded bg-emerald-100 p-4">
|
||||||
<h2 className="mb-2 font-semibold">토큰 정보</h2>
|
<h2 className="mb-2 font-semibold">토큰 정보</h2>
|
||||||
<p>
|
<p>
|
||||||
localStorage 토큰: {typeof window !== "undefined" && localStorage.getItem("authToken") ? "존재" : "없음"}
|
localStorage 토큰: {typeof window !== "undefined" && localStorage.getItem("authToken") ? "존재" : "없음"}
|
||||||
|
|
|
||||||
|
|
@ -220,13 +220,13 @@ export default function LayoutManagementPage() {
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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="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 className="flex items-center justify-between bg-white rounded-lg shadow-sm border p-6">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold text-gray-900">레이아웃 관리</h1>
|
<h1 className="text-3xl font-bold text-foreground">레이아웃 관리</h1>
|
||||||
<p className="mt-2 text-gray-600">화면 레이아웃을 생성하고 관리합니다</p>
|
<p className="mt-2 text-muted-foreground">화면 레이아웃을 생성하고 관리합니다</p>
|
||||||
</div>
|
</div>
|
||||||
<Button className="flex items-center gap-2 shadow-sm" onClick={() => setCreateModalOpen(true)}>
|
<Button className="flex items-center gap-2 shadow-sm" onClick={() => setCreateModalOpen(true)}>
|
||||||
<Plus className="h-4 w-4" />새 레이아웃
|
<Plus className="h-4 w-4" />새 레이아웃
|
||||||
|
|
@ -239,7 +239,7 @@ export default function LayoutManagementPage() {
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="relative">
|
<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
|
<Input
|
||||||
placeholder="레이아웃 이름 또는 설명으로 검색..."
|
placeholder="레이아웃 이름 또는 설명으로 검색..."
|
||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
|
|
@ -276,7 +276,7 @@ export default function LayoutManagementPage() {
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="py-8 text-center">로딩 중...</div>
|
<div className="py-8 text-center">로딩 중...</div>
|
||||||
) : layouts.length === 0 ? (
|
) : 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">
|
<CardHeader className="pb-3">
|
||||||
<div className="flex items-start justify-between">
|
<div className="flex items-start justify-between">
|
||||||
<div className="flex items-center gap-2">
|
<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">
|
<Badge variant="secondary">
|
||||||
{CATEGORY_NAMES[layout.category as keyof typeof CATEGORY_NAMES]}
|
{CATEGORY_NAMES[layout.category as keyof typeof CATEGORY_NAMES]}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
|
@ -312,7 +312,7 @@ export default function LayoutManagementPage() {
|
||||||
<Copy className="mr-2 h-4 w-4" />
|
<Copy className="mr-2 h-4 w-4" />
|
||||||
복제
|
복제
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem onClick={() => handleDelete(layout)} className="text-red-600">
|
<DropdownMenuItem onClick={() => handleDelete(layout)} className="text-destructive">
|
||||||
<Trash2 className="mr-2 h-4 w-4" />
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
삭제
|
삭제
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|
@ -321,17 +321,17 @@ export default function LayoutManagementPage() {
|
||||||
</div>
|
</div>
|
||||||
<CardTitle className="text-lg">{layout.layoutName}</CardTitle>
|
<CardTitle className="text-lg">{layout.layoutName}</CardTitle>
|
||||||
{layout.description && (
|
{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>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center justify-between text-sm">
|
<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>
|
<Badge variant="outline">{layout.layoutType}</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between text-sm">
|
<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>
|
<span>{layout.zonesConfig.length}개</span>
|
||||||
</div>
|
</div>
|
||||||
{layout.isPublic === "Y" && (
|
{layout.isPublic === "Y" && (
|
||||||
|
|
@ -397,7 +397,7 @@ export default function LayoutManagementPage() {
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
<AlertDialogCancel>취소</AlertDialogCancel>
|
<AlertDialogCancel>취소</AlertDialogCancel>
|
||||||
<AlertDialogAction onClick={confirmDelete} className="bg-red-600 hover:bg-red-700">
|
<AlertDialogAction onClick={confirmDelete} className="bg-destructive hover:bg-red-700">
|
||||||
삭제
|
삭제
|
||||||
</AlertDialogAction>
|
</AlertDialogAction>
|
||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
|
|
|
||||||
|
|
@ -59,25 +59,25 @@ export default function MonitoringPage() {
|
||||||
const getStatusIcon = (status: string) => {
|
const getStatusIcon = (status: string) => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 'completed':
|
case 'completed':
|
||||||
return <CheckCircle className="h-4 w-4 text-green-500" />;
|
return <CheckCircle className="h-4 w-4 text-emerald-500" />;
|
||||||
case 'failed':
|
case 'failed':
|
||||||
return <AlertCircle className="h-4 w-4 text-red-500" />;
|
return <AlertCircle className="h-4 w-4 text-destructive" />;
|
||||||
case 'running':
|
case 'running':
|
||||||
return <Play className="h-4 w-4 text-blue-500" />;
|
return <Play className="h-4 w-4 text-primary" />;
|
||||||
case 'pending':
|
case 'pending':
|
||||||
return <Clock className="h-4 w-4 text-yellow-500" />;
|
return <Clock className="h-4 w-4 text-amber-500" />;
|
||||||
default:
|
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 getStatusBadge = (status: string) => {
|
||||||
const variants = {
|
const variants = {
|
||||||
completed: "bg-green-100 text-green-800",
|
completed: "bg-emerald-100 text-emerald-800",
|
||||||
failed: "bg-destructive/20 text-red-800",
|
failed: "bg-destructive/20 text-red-800",
|
||||||
running: "bg-primary/20 text-blue-800",
|
running: "bg-primary/20 text-primary",
|
||||||
pending: "bg-yellow-100 text-yellow-800",
|
pending: "bg-amber-100 text-yellow-800",
|
||||||
cancelled: "bg-gray-100 text-gray-800",
|
cancelled: "bg-muted text-foreground",
|
||||||
};
|
};
|
||||||
|
|
||||||
const labels = {
|
const labels = {
|
||||||
|
|
@ -120,7 +120,7 @@ export default function MonitoringPage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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="w-full max-w-none px-4 py-8 space-y-8">
|
||||||
{/* 헤더 */}
|
{/* 헤더 */}
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -191,7 +191,7 @@ export default function MonitoringPage() {
|
||||||
<div className="text-2xl">✅</div>
|
<div className="text-2xl">✅</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<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">
|
<p className="text-xs text-muted-foreground">
|
||||||
성공률: {getSuccessRate()}%
|
성공률: {getSuccessRate()}%
|
||||||
</p>
|
</p>
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ export default function BarcodeLabelDesignerPage() {
|
||||||
return (
|
return (
|
||||||
<DndProvider backend={HTML5Backend}>
|
<DndProvider backend={HTML5Backend}>
|
||||||
<BarcodeDesignerProvider labelId={labelId}>
|
<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 />
|
<BarcodeDesignerToolbar />
|
||||||
<div className="flex flex-1 overflow-hidden">
|
<div className="flex flex-1 overflow-hidden">
|
||||||
<BarcodeDesignerLeftPanel />
|
<BarcodeDesignerLeftPanel />
|
||||||
|
|
|
||||||
|
|
@ -32,12 +32,12 @@ export default function BarcodeLabelManagementPage() {
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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="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 className="flex items-center justify-between rounded-lg border bg-white p-6 shadow-sm">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold text-gray-900">바코드 라벨 관리</h1>
|
<h1 className="text-3xl font-bold text-foreground">바코드 라벨 관리</h1>
|
||||||
<p className="mt-2 text-gray-600">ZD421 등 바코드 프린터용 라벨을 작성하고 출력합니다</p>
|
<p className="mt-2 text-muted-foreground">ZD421 등 바코드 프린터용 라벨을 작성하고 출력합니다</p>
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={handleCreateNew} className="gap-2">
|
<Button onClick={handleCreateNew} className="gap-2">
|
||||||
<Plus className="h-4 w-4" />
|
<Plus className="h-4 w-4" />
|
||||||
|
|
@ -46,7 +46,7 @@ export default function BarcodeLabelManagementPage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card className="shadow-sm">
|
<Card className="shadow-sm">
|
||||||
<CardHeader className="bg-gray-50/50">
|
<CardHeader className="bg-muted/50">
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
<Search className="h-5 w-5" />
|
<Search className="h-5 w-5" />
|
||||||
검색
|
검색
|
||||||
|
|
@ -105,7 +105,7 @@ export default function BarcodeLabelManagementPage() {
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card className="shadow-sm">
|
<Card className="shadow-sm">
|
||||||
<CardHeader className="bg-gray-50/50">
|
<CardHeader className="bg-muted/50">
|
||||||
<CardTitle className="flex items-center justify-between">
|
<CardTitle className="flex items-center justify-between">
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
바코드 라벨 목록
|
바코드 라벨 목록
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,6 @@ import { dashboardApi } from "@/lib/api/dashboard";
|
||||||
import { Dashboard } from "@/lib/api/dashboard";
|
import { Dashboard } from "@/lib/api/dashboard";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
|
|
@ -16,6 +15,8 @@ import {
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
import { Pagination, PaginationInfo } from "@/components/common/Pagination";
|
import { Pagination, PaginationInfo } from "@/components/common/Pagination";
|
||||||
import { DeleteConfirmModal } from "@/components/common/DeleteConfirmModal";
|
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";
|
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 [dashboards, setDashboards] = useState<Dashboard[]>([]);
|
||||||
const [loading, setLoading] = useState(true); // CSR이므로 초기 로딩 true
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [searchTerm, setSearchTerm] = useState("");
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
|
|
||||||
|
|
@ -83,52 +84,36 @@ export default function DashboardListPage() {
|
||||||
endItem: Math.min(currentPage * pageSize, totalCount),
|
endItem: Math.min(currentPage * pageSize, totalCount),
|
||||||
};
|
};
|
||||||
|
|
||||||
// 페이지 변경 핸들러
|
const handlePageChange = (page: number) => setCurrentPage(page);
|
||||||
const handlePageChange = (page: number) => {
|
|
||||||
setCurrentPage(page);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 페이지 크기 변경 핸들러
|
|
||||||
const handlePageSizeChange = (size: number) => {
|
const handlePageSizeChange = (size: number) => {
|
||||||
setPageSize(size);
|
setPageSize(size);
|
||||||
setCurrentPage(1); // 페이지 크기 변경 시 첫 페이지로
|
setCurrentPage(1);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 대시보드 삭제 확인 모달 열기
|
|
||||||
const handleDeleteClick = (id: string, title: string) => {
|
const handleDeleteClick = (id: string, title: string) => {
|
||||||
setDeleteTarget({ id, title });
|
setDeleteTarget({ id, title });
|
||||||
setDeleteDialogOpen(true);
|
setDeleteDialogOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 대시보드 삭제 실행
|
|
||||||
const handleDeleteConfirm = async () => {
|
const handleDeleteConfirm = async () => {
|
||||||
if (!deleteTarget) return;
|
if (!deleteTarget) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await dashboardApi.deleteDashboard(deleteTarget.id);
|
await dashboardApi.deleteDashboard(deleteTarget.id);
|
||||||
setDeleteDialogOpen(false);
|
setDeleteDialogOpen(false);
|
||||||
setDeleteTarget(null);
|
setDeleteTarget(null);
|
||||||
toast({
|
toast({ title: "성공", description: "대시보드가 삭제되었습니다." });
|
||||||
title: "성공",
|
|
||||||
description: "대시보드가 삭제되었습니다.",
|
|
||||||
});
|
|
||||||
loadDashboards();
|
loadDashboards();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to delete dashboard:", err);
|
console.error("Failed to delete dashboard:", err);
|
||||||
setDeleteDialogOpen(false);
|
setDeleteDialogOpen(false);
|
||||||
toast({
|
toast({ title: "오류", description: "대시보드 삭제에 실패했습니다.", variant: "destructive" });
|
||||||
title: "오류",
|
|
||||||
description: "대시보드 삭제에 실패했습니다.",
|
|
||||||
variant: "destructive",
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 대시보드 복사
|
|
||||||
const handleCopy = async (dashboard: Dashboard) => {
|
const handleCopy = async (dashboard: Dashboard) => {
|
||||||
try {
|
try {
|
||||||
const fullDashboard = await dashboardApi.getDashboard(dashboard.id);
|
const fullDashboard = await dashboardApi.getDashboard(dashboard.id);
|
||||||
|
|
||||||
await dashboardApi.createDashboard({
|
await dashboardApi.createDashboard({
|
||||||
title: `${fullDashboard.title} (복사본)`,
|
title: `${fullDashboard.title} (복사본)`,
|
||||||
description: fullDashboard.description,
|
description: fullDashboard.description,
|
||||||
|
|
@ -138,40 +123,85 @@ export default function DashboardListPage() {
|
||||||
category: fullDashboard.category,
|
category: fullDashboard.category,
|
||||||
settings: fullDashboard.settings as { resolution?: string; backgroundColor?: string },
|
settings: fullDashboard.settings as { resolution?: string; backgroundColor?: string },
|
||||||
});
|
});
|
||||||
toast({
|
toast({ title: "성공", description: "대시보드가 복사되었습니다." });
|
||||||
title: "성공",
|
|
||||||
description: "대시보드가 복사되었습니다.",
|
|
||||||
});
|
|
||||||
loadDashboards();
|
loadDashboards();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to copy dashboard:", err);
|
console.error("Failed to copy dashboard:", err);
|
||||||
toast({
|
toast({ title: "오류", description: "대시보드 복사에 실패했습니다.", variant: "destructive" });
|
||||||
title: "오류",
|
|
||||||
description: "대시보드 복사에 실패했습니다.",
|
|
||||||
variant: "destructive",
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 포맷팅 헬퍼
|
const formatDate = (dateString: string) =>
|
||||||
const formatDate = (dateString: string) => {
|
new Date(dateString).toLocaleDateString("ko-KR", {
|
||||||
return new Date(dateString).toLocaleDateString("ko-KR", {
|
|
||||||
year: "numeric",
|
year: "numeric",
|
||||||
month: "2-digit",
|
month: "2-digit",
|
||||||
day: "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 (
|
return (
|
||||||
<div className="bg-background flex min-h-screen flex-col">
|
<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">
|
<div className="space-y-2 border-b pb-4">
|
||||||
<h1 className="text-3xl font-bold tracking-tight">대시보드 관리</h1>
|
<h1 className="text-3xl font-bold tracking-tight">대시보드 관리</h1>
|
||||||
<p className="text-muted-foreground text-sm">대시보드를 생성하고 관리할 수 있습니다</p>
|
<p className="text-muted-foreground text-sm">대시보드를 생성하고 관리할 수 있습니다</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 검색 및 액션 */}
|
{/* 검색 및 액션 (반응형) */}
|
||||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
<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="flex items-center gap-4">
|
||||||
<div className="relative w-full sm:w-[300px]">
|
<div className="relative w-full sm:w-[300px]">
|
||||||
|
|
@ -183,7 +213,7 @@ export default function DashboardListPage() {
|
||||||
className="h-10 pl-10 text-sm"
|
className="h-10 pl-10 text-sm"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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> 건
|
총 <span className="text-foreground font-semibold">{totalCount.toLocaleString()}</span> 건
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -194,71 +224,7 @@ export default function DashboardListPage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 대시보드 목록 */}
|
{/* 대시보드 목록 */}
|
||||||
{loading ? (
|
{error ? (
|
||||||
<>
|
|
||||||
{/* 데스크톱 테이블 스켈레톤 */}
|
|
||||||
<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 ? (
|
|
||||||
<div className="border-destructive/50 bg-destructive/10 flex flex-col items-center justify-center rounded-lg border p-12">
|
<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="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">
|
<div className="bg-destructive/20 flex h-16 w-16 items-center justify-center rounded-full">
|
||||||
|
|
@ -274,158 +240,50 @@ export default function DashboardListPage() {
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
|
||||||
) : (
|
) : (
|
||||||
<>
|
<ResponsiveDataView<Dashboard>
|
||||||
{/* 데스크톱 테이블 뷰 (lg 이상) */}
|
data={dashboards}
|
||||||
<div className="bg-card hidden shadow-sm lg:block">
|
columns={columns}
|
||||||
<Table>
|
keyExtractor={(d) => d.id}
|
||||||
<TableHeader>
|
isLoading={loading}
|
||||||
<TableRow className="bg-muted/50 hover:bg-muted/50 border-b">
|
emptyMessage="대시보드가 없습니다."
|
||||||
<TableHead className="h-12 text-sm font-semibold">제목</TableHead>
|
skeletonCount={10}
|
||||||
<TableHead className="h-12 text-sm font-semibold">설명</TableHead>
|
cardTitle={(d) => d.title}
|
||||||
<TableHead className="h-12 text-sm font-semibold">생성자</TableHead>
|
cardSubtitle={(d) => d.id}
|
||||||
<TableHead className="h-12 text-sm font-semibold">생성일</TableHead>
|
cardFields={cardFields}
|
||||||
<TableHead className="h-12 text-sm font-semibold">수정일</TableHead>
|
onRowClick={(d) => router.push(`/admin/screenMng/dashboardList/${d.id}`)}
|
||||||
<TableHead className="h-12 text-right text-sm font-semibold">작업</TableHead>
|
renderActions={(d) => (
|
||||||
</TableRow>
|
<DropdownMenu>
|
||||||
</TableHeader>
|
<DropdownMenuTrigger asChild>
|
||||||
<TableBody>
|
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={(e) => e.stopPropagation()}>
|
||||||
{dashboards.map((dashboard) => (
|
<MoreVertical className="h-4 w-4" />
|
||||||
<TableRow key={dashboard.id} className="hover:bg-muted/50 border-b transition-colors">
|
</Button>
|
||||||
<TableCell className="h-16 text-sm font-medium">
|
</DropdownMenuTrigger>
|
||||||
<button
|
<DropdownMenuContent align="end">
|
||||||
onClick={() => router.push(`/admin/screenMng/dashboardList/${dashboard.id}`)}
|
<DropdownMenuItem
|
||||||
className="hover:text-primary cursor-pointer text-left transition-colors hover:underline"
|
onClick={() => router.push(`/admin/screenMng/dashboardList/${d.id}`)}
|
||||||
>
|
className="gap-2 text-sm"
|
||||||
{dashboard.title}
|
>
|
||||||
</button>
|
<Edit className="h-4 w-4" />
|
||||||
</TableCell>
|
편집
|
||||||
<TableCell className="text-muted-foreground h-16 max-w-md truncate text-sm">
|
</DropdownMenuItem>
|
||||||
{dashboard.description || "-"}
|
<DropdownMenuItem onClick={() => handleCopy(d)} className="gap-2 text-sm">
|
||||||
</TableCell>
|
<Copy className="h-4 w-4" />
|
||||||
<TableCell className="text-muted-foreground h-16 text-sm">
|
복사
|
||||||
{dashboard.createdByName || dashboard.createdBy || "-"}
|
</DropdownMenuItem>
|
||||||
</TableCell>
|
<DropdownMenuItem
|
||||||
<TableCell className="text-muted-foreground h-16 text-sm">
|
onClick={() => handleDeleteClick(d.id, d.title)}
|
||||||
{formatDate(dashboard.createdAt)}
|
className="text-destructive focus:text-destructive gap-2 text-sm"
|
||||||
</TableCell>
|
>
|
||||||
<TableCell className="text-muted-foreground h-16 text-sm">
|
<Trash2 className="h-4 w-4" />
|
||||||
{formatDate(dashboard.updatedAt)}
|
삭제
|
||||||
</TableCell>
|
</DropdownMenuItem>
|
||||||
<TableCell className="h-16 text-right">
|
</DropdownMenuContent>
|
||||||
<DropdownMenu>
|
</DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
)}
|
||||||
<Button variant="ghost" size="icon" className="h-8 w-8">
|
actionsLabel="작업"
|
||||||
<MoreVertical className="h-4 w-4" />
|
actionsWidth="80px"
|
||||||
</Button>
|
/>
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end">
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={() => router.push(`/admin/screenMng/dashboardList/${dashboard.id}`)}
|
|
||||||
className="gap-2 text-sm"
|
|
||||||
>
|
|
||||||
<Edit className="h-4 w-4" />
|
|
||||||
편집
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem onClick={() => handleCopy(dashboard)} className="gap-2 text-sm">
|
|
||||||
<Copy className="h-4 w-4" />
|
|
||||||
복사
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={() => handleDeleteClick(dashboard.id, dashboard.title)}
|
|
||||||
className="text-destructive focus:text-destructive gap-2 text-sm"
|
|
||||||
>
|
|
||||||
<Trash2 className="h-4 w-4" />
|
|
||||||
삭제
|
|
||||||
</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>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 페이지네이션 */}
|
{/* 페이지네이션 */}
|
||||||
|
|
@ -453,6 +311,9 @@ export default function DashboardListPage() {
|
||||||
onConfirm={handleDeleteConfirm}
|
onConfirm={handleDeleteConfirm}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Scroll to Top 버튼 */}
|
||||||
|
<ScrollToTop />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -67,7 +67,7 @@ export default function ReportDesignerPage() {
|
||||||
return (
|
return (
|
||||||
<DndProvider backend={HTML5Backend}>
|
<DndProvider backend={HTML5Backend}>
|
||||||
<ReportDesignerProvider reportId={reportId}>
|
<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 />
|
<ReportDesignerToolbar />
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -30,13 +30,13 @@ export default function ReportManagementPage() {
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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="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 className="flex items-center justify-between rounded-lg border bg-white p-6 shadow-sm">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold text-gray-900">리포트 관리</h1>
|
<h1 className="text-3xl font-bold text-foreground">리포트 관리</h1>
|
||||||
<p className="mt-2 text-gray-600">리포트를 생성하고 관리합니다</p>
|
<p className="mt-2 text-muted-foreground">리포트를 생성하고 관리합니다</p>
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={handleCreateNew} className="gap-2">
|
<Button onClick={handleCreateNew} className="gap-2">
|
||||||
<Plus className="h-4 w-4" />새 리포트
|
<Plus className="h-4 w-4" />새 리포트
|
||||||
|
|
@ -45,7 +45,7 @@ export default function ReportManagementPage() {
|
||||||
|
|
||||||
{/* 검색 영역 */}
|
{/* 검색 영역 */}
|
||||||
<Card className="shadow-sm">
|
<Card className="shadow-sm">
|
||||||
<CardHeader className="bg-gray-50/50">
|
<CardHeader className="bg-muted/50">
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
<Search className="h-5 w-5" />
|
<Search className="h-5 w-5" />
|
||||||
검색
|
검색
|
||||||
|
|
@ -78,7 +78,7 @@ export default function ReportManagementPage() {
|
||||||
|
|
||||||
{/* 리포트 목록 */}
|
{/* 리포트 목록 */}
|
||||||
<Card className="shadow-sm">
|
<Card className="shadow-sm">
|
||||||
<CardHeader className="bg-gray-50/50">
|
<CardHeader className="bg-muted/50">
|
||||||
<CardTitle className="flex items-center justify-between">
|
<CardTitle className="flex items-center justify-between">
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
📋 리포트 목록
|
📋 리포트 목록
|
||||||
|
|
|
||||||
|
|
@ -204,7 +204,7 @@ export default function EditWebTypePage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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="w-full max-w-none px-4 py-8 space-y-8">
|
||||||
{/* 헤더 */}
|
{/* 헤더 */}
|
||||||
<div className="mb-6 flex items-center gap-4">
|
<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="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="type_name">
|
<Label htmlFor="type_name">
|
||||||
웹타입명 <span className="text-red-500">*</span>
|
웹타입명 <span className="text-destructive">*</span>
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="type_name"
|
id="type_name"
|
||||||
|
|
@ -267,7 +267,7 @@ export default function EditWebTypePage() {
|
||||||
{/* 카테고리 */}
|
{/* 카테고리 */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="category">
|
<Label htmlFor="category">
|
||||||
카테고리 <span className="text-red-500">*</span>
|
카테고리 <span className="text-destructive">*</span>
|
||||||
</Label>
|
</Label>
|
||||||
<Select value={formData.category || ""} onValueChange={(value) => handleInputChange("category", value)}>
|
<Select value={formData.category || ""} onValueChange={(value) => handleInputChange("category", value)}>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
|
|
@ -286,7 +286,7 @@ export default function EditWebTypePage() {
|
||||||
{/* 연결된 컴포넌트 */}
|
{/* 연결된 컴포넌트 */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="component_name">
|
<Label htmlFor="component_name">
|
||||||
연결된 컴포넌트 <span className="text-red-500">*</span>
|
연결된 컴포넌트 <span className="text-destructive">*</span>
|
||||||
</Label>
|
</Label>
|
||||||
<Select
|
<Select
|
||||||
value={formData.component_name || "TextWidget"}
|
value={formData.component_name || "TextWidget"}
|
||||||
|
|
@ -338,15 +338,15 @@ export default function EditWebTypePage() {
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
{formData.config_panel && (
|
{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="flex items-center gap-2">
|
||||||
<div className="h-2 w-2 rounded-full bg-green-500"></div>
|
<div className="h-2 w-2 rounded-full bg-emerald-500"></div>
|
||||||
<span className="text-sm font-medium text-green-700">
|
<span className="text-sm font-medium text-emerald-700">
|
||||||
현재 선택: {getConfigPanelInfo(formData.config_panel)?.label || formData.config_panel}
|
현재 선택: {getConfigPanelInfo(formData.config_panel)?.label || formData.config_panel}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{getConfigPanelInfo(formData.config_panel)?.description && (
|
{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}
|
{getConfigPanelInfo(formData.config_panel)?.description}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
@ -427,7 +427,7 @@ export default function EditWebTypePage() {
|
||||||
rows={4}
|
rows={4}
|
||||||
className="font-mono text-xs"
|
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>
|
</div>
|
||||||
|
|
||||||
{/* 유효성 검사 규칙 */}
|
{/* 유효성 검사 규칙 */}
|
||||||
|
|
@ -441,7 +441,7 @@ export default function EditWebTypePage() {
|
||||||
rows={4}
|
rows={4}
|
||||||
className="font-mono text-xs"
|
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>
|
</div>
|
||||||
|
|
||||||
{/* 기본 스타일 */}
|
{/* 기본 스타일 */}
|
||||||
|
|
@ -455,7 +455,7 @@ export default function EditWebTypePage() {
|
||||||
rows={4}
|
rows={4}
|
||||||
className="font-mono text-xs"
|
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>
|
</div>
|
||||||
|
|
||||||
{/* 입력 속성 */}
|
{/* 입력 속성 */}
|
||||||
|
|
@ -469,7 +469,7 @@ export default function EditWebTypePage() {
|
||||||
rows={4}
|
rows={4}
|
||||||
className="font-mono text-xs"
|
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>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|
@ -498,8 +498,8 @@ export default function EditWebTypePage() {
|
||||||
|
|
||||||
{/* 에러 메시지 */}
|
{/* 에러 메시지 */}
|
||||||
{updateError && (
|
{updateError && (
|
||||||
<div className="mt-4 rounded-md border border-red-200 bg-red-50 p-4">
|
<div className="mt-4 rounded-md border border-destructive/20 bg-destructive/10 p-4">
|
||||||
<p className="text-red-600">
|
<p className="text-destructive">
|
||||||
수정 중 오류가 발생했습니다: {updateError instanceof Error ? updateError.message : "알 수 없는 오류"}
|
수정 중 오류가 발생했습니다: {updateError instanceof Error ? updateError.message : "알 수 없는 오류"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -56,7 +56,7 @@ export default function WebTypeDetailPage() {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-96 items-center justify-center">
|
<div className="flex h-96 items-center justify-center">
|
||||||
<div className="text-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">
|
<Link href="/admin/standards">
|
||||||
<Button variant="outline">목록으로 돌아가기</Button>
|
<Button variant="outline">목록으로 돌아가기</Button>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
@ -80,7 +80,7 @@ export default function WebTypeDetailPage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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="w-full max-w-none px-4 py-8 space-y-8">
|
||||||
{/* 헤더 */}
|
{/* 헤더 */}
|
||||||
<div className="mb-6 flex items-center justify-between">
|
<div className="mb-6 flex items-center justify-between">
|
||||||
|
|
|
||||||
|
|
@ -159,7 +159,7 @@ export default function NewWebTypePage() {
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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="w-full max-w-none px-4 py-8 space-y-8">
|
||||||
{/* 헤더 */}
|
{/* 헤더 */}
|
||||||
<div className="mb-6 flex items-center gap-4">
|
<div className="mb-6 flex items-center gap-4">
|
||||||
|
|
@ -186,7 +186,7 @@ export default function NewWebTypePage() {
|
||||||
{/* 웹타입 코드 */}
|
{/* 웹타입 코드 */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="web_type">
|
<Label htmlFor="web_type">
|
||||||
웹타입 코드 <span className="text-red-500">*</span>
|
웹타입 코드 <span className="text-destructive">*</span>
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="web_type"
|
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="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="type_name">
|
<Label htmlFor="type_name">
|
||||||
웹타입명 <span className="text-red-500">*</span>
|
웹타입명 <span className="text-destructive">*</span>
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="type_name"
|
id="type_name"
|
||||||
|
|
@ -225,7 +225,7 @@ export default function NewWebTypePage() {
|
||||||
{/* 카테고리 */}
|
{/* 카테고리 */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="category">
|
<Label htmlFor="category">
|
||||||
카테고리 <span className="text-red-500">*</span>
|
카테고리 <span className="text-destructive">*</span>
|
||||||
</Label>
|
</Label>
|
||||||
<Select value={formData.category} onValueChange={(value) => handleInputChange("category", value)}>
|
<Select value={formData.category} onValueChange={(value) => handleInputChange("category", value)}>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
|
|
@ -244,7 +244,7 @@ export default function NewWebTypePage() {
|
||||||
{/* 연결된 컴포넌트 */}
|
{/* 연결된 컴포넌트 */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="component_name">
|
<Label htmlFor="component_name">
|
||||||
연결된 컴포넌트 <span className="text-red-500">*</span>
|
연결된 컴포넌트 <span className="text-destructive">*</span>
|
||||||
</Label>
|
</Label>
|
||||||
<Select
|
<Select
|
||||||
value={formData.component_name || "TextWidget"}
|
value={formData.component_name || "TextWidget"}
|
||||||
|
|
@ -296,15 +296,15 @@ export default function NewWebTypePage() {
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
{formData.config_panel && (
|
{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="flex items-center gap-2">
|
||||||
<div className="h-2 w-2 rounded-full bg-green-500"></div>
|
<div className="h-2 w-2 rounded-full bg-emerald-500"></div>
|
||||||
<span className="text-sm font-medium text-green-700">
|
<span className="text-sm font-medium text-emerald-700">
|
||||||
현재 선택: {getConfigPanelInfo(formData.config_panel)?.label || formData.config_panel}
|
현재 선택: {getConfigPanelInfo(formData.config_panel)?.label || formData.config_panel}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{getConfigPanelInfo(formData.config_panel)?.description && (
|
{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}
|
{getConfigPanelInfo(formData.config_panel)?.description}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
@ -385,7 +385,7 @@ export default function NewWebTypePage() {
|
||||||
rows={4}
|
rows={4}
|
||||||
className="font-mono text-xs"
|
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>
|
</div>
|
||||||
|
|
||||||
{/* 유효성 검사 규칙 */}
|
{/* 유효성 검사 규칙 */}
|
||||||
|
|
@ -399,7 +399,7 @@ export default function NewWebTypePage() {
|
||||||
rows={4}
|
rows={4}
|
||||||
className="font-mono text-xs"
|
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>
|
</div>
|
||||||
|
|
||||||
{/* 기본 스타일 */}
|
{/* 기본 스타일 */}
|
||||||
|
|
@ -413,7 +413,7 @@ export default function NewWebTypePage() {
|
||||||
rows={4}
|
rows={4}
|
||||||
className="font-mono text-xs"
|
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>
|
</div>
|
||||||
|
|
||||||
{/* 입력 속성 */}
|
{/* 입력 속성 */}
|
||||||
|
|
@ -427,7 +427,7 @@ export default function NewWebTypePage() {
|
||||||
rows={4}
|
rows={4}
|
||||||
className="font-mono text-xs"
|
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>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|
@ -448,8 +448,8 @@ export default function NewWebTypePage() {
|
||||||
|
|
||||||
{/* 에러 메시지 */}
|
{/* 에러 메시지 */}
|
||||||
{createError && (
|
{createError && (
|
||||||
<div className="mt-4 rounded-md border border-red-200 bg-red-50 p-4">
|
<div className="mt-4 rounded-md border border-destructive/20 bg-destructive/10 p-4">
|
||||||
<p className="text-red-600">
|
<p className="text-destructive">
|
||||||
생성 중 오류가 발생했습니다: {createError instanceof Error ? createError.message : "알 수 없는 오류"}
|
생성 중 오류가 발생했습니다: {createError instanceof Error ? createError.message : "알 수 없는 오류"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,6 @@ import React, { useState, useMemo } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Badge } from "@/components/ui/badge";
|
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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
|
|
@ -20,9 +18,11 @@ import {
|
||||||
} from "@/components/ui/alert-dialog";
|
} from "@/components/ui/alert-dialog";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { showErrorToast } from "@/lib/utils/toastUtils";
|
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 { useWebTypes } from "@/hooks/admin/useWebTypes";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { ResponsiveDataView, RDVColumn, RDVCardField } from "@/components/common/ResponsiveDataView";
|
||||||
|
import type { WebTypeStandard } from "@/hooks/admin/useWebTypes";
|
||||||
|
|
||||||
export default function WebTypesManagePage() {
|
export default function WebTypesManagePage() {
|
||||||
const [searchTerm, setSearchTerm] = useState("");
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
|
|
@ -31,35 +31,29 @@ export default function WebTypesManagePage() {
|
||||||
const [sortField, setSortField] = useState<string>("sort_order");
|
const [sortField, setSortField] = useState<string>("sort_order");
|
||||||
const [sortDirection, setSortDirection] = useState<"asc" | "desc">("asc");
|
const [sortDirection, setSortDirection] = useState<"asc" | "desc">("asc");
|
||||||
|
|
||||||
// 웹타입 데이터 조회
|
|
||||||
const { webTypes, isLoading, error, deleteWebType, isDeleting, deleteError, refetch } = useWebTypes({
|
const { webTypes, isLoading, error, deleteWebType, isDeleting, deleteError, refetch } = useWebTypes({
|
||||||
active: activeFilter === "all" ? undefined : activeFilter,
|
active: activeFilter === "all" ? undefined : activeFilter,
|
||||||
search: searchTerm || undefined,
|
search: searchTerm || undefined,
|
||||||
category: categoryFilter === "all" ? undefined : categoryFilter,
|
category: categoryFilter === "all" ? undefined : categoryFilter,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 카테고리 목록 생성
|
|
||||||
const categories = useMemo(() => {
|
const categories = useMemo(() => {
|
||||||
const uniqueCategories = Array.from(new Set(webTypes.map((wt) => wt.category).filter(Boolean)));
|
const uniqueCategories = Array.from(new Set(webTypes.map((wt) => wt.category).filter(Boolean)));
|
||||||
return uniqueCategories.sort();
|
return uniqueCategories.sort();
|
||||||
}, [webTypes]);
|
}, [webTypes]);
|
||||||
|
|
||||||
// 필터링 및 정렬된 데이터
|
|
||||||
const filteredAndSortedWebTypes = useMemo(() => {
|
const filteredAndSortedWebTypes = useMemo(() => {
|
||||||
let filtered = [...webTypes];
|
let filtered = [...webTypes];
|
||||||
|
|
||||||
// 정렬
|
|
||||||
filtered.sort((a, b) => {
|
filtered.sort((a, b) => {
|
||||||
let aValue: any = a[sortField as keyof typeof a];
|
let aValue: any = a[sortField as keyof typeof a];
|
||||||
let bValue: any = b[sortField as keyof typeof b];
|
let bValue: any = b[sortField as keyof typeof b];
|
||||||
|
|
||||||
// 숫자 필드 처리
|
|
||||||
if (sortField === "sort_order") {
|
if (sortField === "sort_order") {
|
||||||
aValue = aValue || 0;
|
aValue = aValue || 0;
|
||||||
bValue = bValue || 0;
|
bValue = bValue || 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 문자열 필드 처리
|
|
||||||
if (typeof aValue === "string") {
|
if (typeof aValue === "string") {
|
||||||
aValue = aValue.toLowerCase();
|
aValue = aValue.toLowerCase();
|
||||||
}
|
}
|
||||||
|
|
@ -75,17 +69,6 @@ export default function WebTypesManagePage() {
|
||||||
return filtered;
|
return filtered;
|
||||||
}, [webTypes, sortField, sortDirection]);
|
}, [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) => {
|
const handleDelete = async (webType: string, typeName: string) => {
|
||||||
try {
|
try {
|
||||||
await deleteWebType(webType);
|
await deleteWebType(webType);
|
||||||
|
|
@ -95,7 +78,6 @@ export default function WebTypesManagePage() {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 필터 초기화
|
|
||||||
const resetFilters = () => {
|
const resetFilters = () => {
|
||||||
setSearchTerm("");
|
setSearchTerm("");
|
||||||
setCategoryFilter("all");
|
setCategoryFilter("all");
|
||||||
|
|
@ -104,267 +86,266 @@ export default function WebTypesManagePage() {
|
||||||
setSortDirection("asc");
|
setSortDirection("asc");
|
||||||
};
|
};
|
||||||
|
|
||||||
// 로딩 상태
|
// 삭제 AlertDialog 렌더 헬퍼
|
||||||
if (isLoading) {
|
const renderDeleteDialog = (wt: WebTypeStandard) => (
|
||||||
return (
|
<AlertDialog>
|
||||||
<div className="flex h-96 items-center justify-center">
|
<AlertDialogTrigger asChild>
|
||||||
<div className="text-lg">웹타입 목록을 불러오는 중...</div>
|
<Button variant="ghost" size="sm">
|
||||||
</div>
|
<Trash2 className="h-4 w-4 text-destructive" />
|
||||||
);
|
</Button>
|
||||||
}
|
</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>[] = [
|
||||||
if (error) {
|
{
|
||||||
return (
|
key: "sort_order",
|
||||||
<div className="flex h-96 items-center justify-center">
|
label: "순서",
|
||||||
<div className="text-center">
|
width: "80px",
|
||||||
<div className="mb-2 text-lg text-destructive">웹타입 목록을 불러오는데 실패했습니다.</div>
|
render: (_val, wt) => <span className="font-mono">{wt.sort_order || 0}</span>,
|
||||||
<Button onClick={() => refetch()} variant="outline">
|
},
|
||||||
|
{
|
||||||
|
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="space-y-6">
|
||||||
|
{/* 페이지 헤더 */}
|
||||||
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold">웹타입 관리</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">화면관리에서 사용할 웹타입들을 관리합니다</p>
|
||||||
|
</div>
|
||||||
|
<Link href="/admin/standards/new">
|
||||||
|
<Button className="w-full sm:w-auto">
|
||||||
|
<Plus className="mr-2 h-4 w-4" />새 웹타입 추가
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 에러 상태 */}
|
||||||
|
{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>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
{/* 검색 툴바 */}
|
||||||
<div className="min-h-screen bg-background">
|
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||||
<div className="w-full max-w-none px-4 py-8 space-y-8">
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||||
{/* 페이지 제목 */}
|
<div className="relative w-full sm:w-[300px]">
|
||||||
<div className="flex items-center justify-between bg-background rounded-lg shadow-sm border p-6">
|
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
<div>
|
<Input
|
||||||
<h1 className="text-3xl font-bold text-foreground">웹타입 관리</h1>
|
placeholder="웹타입명, 설명 검색..."
|
||||||
<p className="mt-2 text-muted-foreground">화면관리에서 사용할 웹타입들을 관리합니다</p>
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
className="h-10 pl-10 text-sm"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Link href="/admin/standards/new">
|
|
||||||
<Button className="shadow-sm">
|
|
||||||
<Plus className="mr-2 h-4 w-4" />새 웹타입 추가
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 필터 및 검색 */}
|
<Select value={categoryFilter} onValueChange={setCategoryFilter}>
|
||||||
<Card className="shadow-sm">
|
<SelectTrigger className="h-10 w-full sm:w-[160px]">
|
||||||
<CardHeader className="bg-muted/50">
|
<SelectValue placeholder="카테고리 선택" />
|
||||||
<CardTitle className="flex items-center gap-2 text-lg">
|
</SelectTrigger>
|
||||||
<Filter className="h-5 w-5 text-muted-foreground" />
|
<SelectContent>
|
||||||
필터 및 검색
|
<SelectItem value="all">전체 카테고리</SelectItem>
|
||||||
</CardTitle>
|
{categories.map((category) => (
|
||||||
</CardHeader>
|
<SelectItem key={category} value={category}>
|
||||||
<CardContent className="space-y-4">
|
{category}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
</SelectItem>
|
||||||
{/* 검색 */}
|
))}
|
||||||
<div className="relative">
|
</SelectContent>
|
||||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
</Select>
|
||||||
<Input
|
|
||||||
placeholder="웹타입명, 설명 검색..."
|
|
||||||
value={searchTerm}
|
|
||||||
onChange={(e) => setSearchTerm(e.target.value)}
|
|
||||||
className="pl-10"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 카테고리 필터 */}
|
<Select value={activeFilter} onValueChange={setActiveFilter}>
|
||||||
<Select value={categoryFilter} onValueChange={setCategoryFilter}>
|
<SelectTrigger className="h-10 w-full sm:w-[160px]">
|
||||||
<SelectTrigger>
|
<SelectValue placeholder="상태 선택" />
|
||||||
<SelectValue placeholder="카테고리 선택" />
|
</SelectTrigger>
|
||||||
</SelectTrigger>
|
<SelectContent>
|
||||||
|
<SelectItem value="all">전체</SelectItem>
|
||||||
|
<SelectItem value="Y">활성화</SelectItem>
|
||||||
|
<SelectItem value="N">비활성화</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
{/* 정렬 선택 */}
|
||||||
|
<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>
|
<SelectContent>
|
||||||
<SelectItem value="all">전체 카테고리</SelectItem>
|
<SelectItem value="sort_order">순서</SelectItem>
|
||||||
{categories.map((category) => (
|
<SelectItem value="web_type">웹타입 코드</SelectItem>
|
||||||
<SelectItem key={category} value={category}>
|
<SelectItem value="type_name">웹타입명</SelectItem>
|
||||||
{category}
|
<SelectItem value="category">카테고리</SelectItem>
|
||||||
</SelectItem>
|
<SelectItem value="is_active">상태</SelectItem>
|
||||||
))}
|
<SelectItem value="updated_date">수정일</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
<Button
|
||||||
{/* 활성화 상태 필터 */}
|
variant="outline"
|
||||||
<Select value={activeFilter} onValueChange={setActiveFilter}>
|
size="icon"
|
||||||
<SelectTrigger>
|
onClick={() => setSortDirection(sortDirection === "asc" ? "desc" : "asc")}
|
||||||
<SelectValue placeholder="상태 선택" />
|
className="h-10 w-10 shrink-0"
|
||||||
</SelectTrigger>
|
title={sortDirection === "asc" ? "오름차순" : "내림차순"}
|
||||||
<SelectContent>
|
>
|
||||||
<SelectItem value="all">전체</SelectItem>
|
{sortDirection === "asc" ? <SortAsc className="h-4 w-4" /> : <SortDesc className="h-4 w-4" />}
|
||||||
<SelectItem value="Y">활성화</SelectItem>
|
</Button>
|
||||||
<SelectItem value="N">비활성화</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
|
|
||||||
{/* 초기화 버튼 */}
|
|
||||||
<Button variant="outline" onClick={resetFilters}>
|
|
||||||
<RotateCcw className="mr-2 h-4 w-4" />
|
|
||||||
초기화
|
|
||||||
</Button>
|
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
{/* 웹타입 목록 테이블 */}
|
<Button variant="outline" onClick={resetFilters} className="h-10 w-full sm:w-auto">
|
||||||
<div className="bg-card shadow-sm">
|
<RotateCcw className="mr-2 h-4 w-4" />
|
||||||
<Table>
|
초기화
|
||||||
<TableHeader>
|
</Button>
|
||||||
<TableRow className="bg-background">
|
</div>
|
||||||
<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>
|
|
||||||
|
|
||||||
|
{/* 결과 수 */}
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
총 <span className="font-semibold text-foreground">{filteredAndSortedWebTypes.length}</span>개의 웹타입
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 삭제 에러 */}
|
||||||
{deleteError && (
|
{deleteError && (
|
||||||
<div className="mt-4 rounded-md border border-destructive/30 bg-destructive/10 p-4">
|
<div className="rounded-lg border border-destructive/50 bg-destructive/10 p-4">
|
||||||
<p className="text-destructive">
|
<p className="text-sm text-destructive">
|
||||||
삭제 중 오류가 발생했습니다: {deleteError instanceof Error ? deleteError.message : "알 수 없는 오류"}
|
삭제 중 오류가 발생했습니다: {deleteError instanceof Error ? deleteError.message : "알 수 없는 오류"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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>
|
</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) => {
|
const getStatusBadge = (isActive: string) => {
|
||||||
return isActive === "Y" ? (
|
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 getTypeBadge = (type: string) => {
|
||||||
const option = collectionTypeOptions.find(opt => opt.value === type);
|
const option = collectionTypeOptions.find(opt => opt.value === type);
|
||||||
const colors = {
|
const colors = {
|
||||||
full: "bg-blue-100 text-blue-800",
|
full: "bg-primary/10 text-primary",
|
||||||
incremental: "bg-purple-100 text-purple-800",
|
incremental: "bg-purple-100 text-purple-800",
|
||||||
delta: "bg-orange-100 text-orange-800",
|
delta: "bg-amber-100 text-orange-800",
|
||||||
};
|
};
|
||||||
return (
|
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}
|
{option?.label || type}
|
||||||
</Badge>
|
</Badge>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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="w-full max-w-none px-4 py-8 space-y-8">
|
||||||
{/* 헤더 */}
|
{/* 헤더 */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
|
|
|
||||||
|
|
@ -51,8 +51,8 @@ export default function DataFlowEditPage() {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-64 items-center justify-center">
|
<div className="flex h-64 items-center justify-center">
|
||||||
<div className="text-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>
|
<div className="mx-auto mb-4 h-8 w-8 animate-spin rounded-full border-b-2 border-primary"></div>
|
||||||
<p className="text-gray-500">관계도 정보를 불러오는 중...</p>
|
<p className="text-muted-foreground">관계도 정보를 불러오는 중...</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -68,16 +68,16 @@ export default function DataFlowEditPage() {
|
||||||
<span>목록으로</span>
|
<span>목록으로</span>
|
||||||
</Button>
|
</Button>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-gray-900">📊 관계도 편집</h1>
|
<h1 className="text-2xl font-bold text-foreground">📊 관계도 편집</h1>
|
||||||
<p className="mt-1 text-gray-600">
|
<p className="mt-1 text-muted-foreground">
|
||||||
<span className="font-medium text-blue-600">{diagramName}</span> 관계도를 편집하고 있습니다
|
<span className="font-medium text-primary">{diagramName}</span> 관계도를 편집하고 있습니다
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 데이터플로우 디자이너 */}
|
{/* 데이터플로우 디자이너 */}
|
||||||
<div className="rounded-lg border border-gray-200 bg-white">
|
<div className="rounded-lg border border-border bg-white">
|
||||||
<DataFlowDesigner
|
<DataFlowDesigner
|
||||||
key={diagramId}
|
key={diagramId}
|
||||||
selectedDiagram={diagramName}
|
selectedDiagram={diagramName}
|
||||||
|
|
|
||||||
|
|
@ -17,8 +17,8 @@ export default function NodeEditorPage() {
|
||||||
}, [router]);
|
}, [router]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen items-center justify-center bg-gray-50">
|
<div className="flex h-screen items-center justify-center bg-muted">
|
||||||
<div className="text-gray-500">제어 관리 페이지로 이동중...</div>
|
<div className="text-muted-foreground">제어 관리 페이지로 이동중...</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -528,14 +528,14 @@ export default function I18nPage() {
|
||||||
? "공통"
|
? "공통"
|
||||||
: companies.find((c) => c.code === row.original.companyCode)?.name || row.original.companyCode;
|
: 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",
|
accessorKey: "menuName",
|
||||||
header: "메뉴명",
|
header: "메뉴명",
|
||||||
cell: ({ row }: any) => (
|
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: "언어 키",
|
header: "언어 키",
|
||||||
cell: ({ row }: any) => (
|
cell: ({ row }: any) => (
|
||||||
<div
|
<div
|
||||||
className={`cursor-pointer rounded p-1 hover:bg-gray-100 ${
|
className={`cursor-pointer rounded p-1 hover:bg-muted ${
|
||||||
row.original.isActive === "N" ? "text-gray-400" : ""
|
row.original.isActive === "N" ? "text-muted-foreground/70" : ""
|
||||||
}`}
|
}`}
|
||||||
onDoubleClick={() => handleEditKey(row.original)}
|
onDoubleClick={() => handleEditKey(row.original)}
|
||||||
>
|
>
|
||||||
|
|
@ -556,7 +556,7 @@ export default function I18nPage() {
|
||||||
accessorKey: "description",
|
accessorKey: "description",
|
||||||
header: "설명",
|
header: "설명",
|
||||||
cell: ({ row }: any) => (
|
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)}
|
onClick={() => handleToggleStatus(row.original.keyId)}
|
||||||
className={`rounded px-2 py-1 text-xs font-medium transition-colors ${
|
className={`rounded px-2 py-1 text-xs font-medium transition-colors ${
|
||||||
row.original.isActive === "Y"
|
row.original.isActive === "Y"
|
||||||
? "bg-green-100 text-green-800 hover:bg-green-200"
|
? "bg-emerald-100 text-emerald-800 hover:bg-emerald-200"
|
||||||
: "bg-gray-100 text-gray-800 hover:bg-gray-200"
|
: "bg-muted text-foreground hover:bg-muted/80"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{row.original.isActive === "Y" ? "활성" : "비활성"}
|
{row.original.isActive === "Y" ? "활성" : "비활성"}
|
||||||
|
|
@ -605,8 +605,8 @@ export default function I18nPage() {
|
||||||
header: "언어 코드",
|
header: "언어 코드",
|
||||||
cell: ({ row }: any) => (
|
cell: ({ row }: any) => (
|
||||||
<div
|
<div
|
||||||
className={`cursor-pointer rounded p-1 hover:bg-gray-100 ${
|
className={`cursor-pointer rounded p-1 hover:bg-muted ${
|
||||||
row.original.isActive === "N" ? "text-gray-400" : ""
|
row.original.isActive === "N" ? "text-muted-foreground/70" : ""
|
||||||
}`}
|
}`}
|
||||||
onDoubleClick={() => handleEditLanguage(row.original)}
|
onDoubleClick={() => handleEditLanguage(row.original)}
|
||||||
>
|
>
|
||||||
|
|
@ -618,14 +618,14 @@ export default function I18nPage() {
|
||||||
accessorKey: "langName",
|
accessorKey: "langName",
|
||||||
header: "언어명 (영문)",
|
header: "언어명 (영문)",
|
||||||
cell: ({ row }: any) => (
|
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",
|
accessorKey: "langNative",
|
||||||
header: "언어명 (원어)",
|
header: "언어명 (원어)",
|
||||||
cell: ({ row }: any) => (
|
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)}
|
onClick={() => handleToggleLanguageStatus(row.original.langCode)}
|
||||||
className={`rounded px-2 py-1 text-xs font-medium transition-colors ${
|
className={`rounded px-2 py-1 text-xs font-medium transition-colors ${
|
||||||
row.original.isActive === "Y"
|
row.original.isActive === "Y"
|
||||||
? "bg-green-100 text-green-800 hover:bg-green-200"
|
? "bg-emerald-100 text-emerald-800 hover:bg-emerald-200"
|
||||||
: "bg-gray-100 text-gray-800 hover:bg-gray-200"
|
: "bg-muted text-foreground hover:bg-muted/80"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{row.original.isActive === "Y" ? "활성" : "비활성"}
|
{row.original.isActive === "Y" ? "활성" : "비활성"}
|
||||||
|
|
@ -651,7 +651,7 @@ export default function I18nPage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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="w-full max-w-none px-4 py-8">
|
||||||
<div className="container mx-auto p-2">
|
<div className="container mx-auto p-2">
|
||||||
{/* 탭 네비게이션 */}
|
{/* 탭 네비게이션 */}
|
||||||
|
|
@ -659,7 +659,7 @@ export default function I18nPage() {
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveTab("keys")}
|
onClick={() => setActiveTab("keys")}
|
||||||
className={`rounded-t-lg px-3 py-1.5 text-sm font-medium transition-colors ${
|
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
|
<button
|
||||||
onClick={() => setActiveTab("languages")}
|
onClick={() => setActiveTab("languages")}
|
||||||
className={`rounded-t-lg px-3 py-1.5 text-sm font-medium transition-colors ${
|
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>
|
</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="text-center">
|
||||||
<div className="mb-2 text-lg font-medium">언어 키를 선택하세요</div>
|
<div className="mb-2 text-lg font-medium">언어 키를 선택하세요</div>
|
||||||
<div className="text-sm">좌측 목록에서 편집할 언어 키를 클릭하세요</div>
|
<div className="text-sm">좌측 목록에서 편집할 언어 키를 클릭하세요</div>
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ import {
|
||||||
ChevronsUpDown,
|
ChevronsUpDown,
|
||||||
Loader2,
|
Loader2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import { ResponsiveSplitPanel } from "@/components/common/ResponsiveSplitPanel";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { LoadingSpinner } from "@/components/common/LoadingSpinner";
|
import { LoadingSpinner } from "@/components/common/LoadingSpinner";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
@ -1409,9 +1410,8 @@ export default function TableManagementPage() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex h-full flex-1 gap-6 overflow-hidden">
|
<ResponsiveSplitPanel
|
||||||
{/* 좌측 사이드바: 테이블 목록 (20%) */}
|
left={
|
||||||
<div className="flex h-full w-[20%] flex-col border-r pr-4">
|
|
||||||
<div className="flex h-full flex-col space-y-4">
|
<div className="flex h-full flex-col space-y-4">
|
||||||
{/* 검색 */}
|
{/* 검색 */}
|
||||||
<div className="flex-shrink-0">
|
<div className="flex-shrink-0">
|
||||||
|
|
@ -1530,10 +1530,8 @@ export default function TableManagementPage() {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
}
|
||||||
|
right={
|
||||||
{/* 우측 메인 영역: 컬럼 타입 관리 (80%) */}
|
|
||||||
<div className="flex h-full w-[80%] flex-col overflow-hidden pl-0">
|
|
||||||
<div className="flex h-full flex-col overflow-hidden">
|
<div className="flex h-full flex-col overflow-hidden">
|
||||||
{!selectedTable ? (
|
{!selectedTable ? (
|
||||||
<div className="bg-card flex h-64 flex-col items-center justify-center rounded-lg border">
|
<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) => (
|
{referenceTableColumns[column.referenceTable]?.map((refCol) => (
|
||||||
<CommandItem
|
<CommandItem
|
||||||
key={refCol.columnName}
|
key={refCol.columnName}
|
||||||
value={`${refCol.columnLabel || ""} ${refCol.columnName}`}
|
value={`${refCol.displayName || ""} ${refCol.columnName}`}
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
handleDetailSettingsChange(
|
handleDetailSettingsChange(
|
||||||
column.columnName,
|
column.columnName,
|
||||||
|
|
@ -1905,9 +1903,9 @@ export default function TableManagementPage() {
|
||||||
/>
|
/>
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<span className="font-medium">{refCol.columnName}</span>
|
<span className="font-medium">{refCol.columnName}</span>
|
||||||
{refCol.columnLabel && (
|
{refCol.displayName && (
|
||||||
<span className="text-muted-foreground text-[10px]">
|
<span className="text-muted-foreground text-[10px]">
|
||||||
{refCol.columnLabel}
|
{refCol.displayName}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -2006,8 +2004,14 @@ export default function TableManagementPage() {
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
}
|
||||||
</div>
|
leftTitle="테이블 목록"
|
||||||
|
leftWidth={20}
|
||||||
|
minLeftWidth={10}
|
||||||
|
maxLeftWidth={35}
|
||||||
|
height="100%"
|
||||||
|
className="flex-1 overflow-hidden"
|
||||||
|
/>
|
||||||
|
|
||||||
{/* DDL 모달 컴포넌트들 */}
|
{/* DDL 모달 컴포넌트들 */}
|
||||||
{isSuperAdmin && (
|
{isSuperAdmin && (
|
||||||
|
|
|
||||||
|
|
@ -120,12 +120,12 @@ export default function TemplatesManagePage() {
|
||||||
|
|
||||||
// 간단한 아이콘 매핑 (실제로는 더 복잡한 시스템 필요)
|
// 간단한 아이콘 매핑 (실제로는 더 복잡한 시스템 필요)
|
||||||
const iconMap: Record<string, JSX.Element> = {
|
const iconMap: Record<string, JSX.Element> = {
|
||||||
table: <div className="h-4 w-4 border border-gray-400" />,
|
table: <div className="h-4 w-4 border border-input" />,
|
||||||
"mouse-pointer": <div className="h-4 w-4 rounded bg-blue-500" />,
|
"mouse-pointer": <div className="h-4 w-4 rounded bg-primary" />,
|
||||||
upload: <div className="h-4 w-4 border-2 border-dashed border-gray-400" />,
|
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) {
|
if (error) {
|
||||||
|
|
@ -133,7 +133,7 @@ export default function TemplatesManagePage() {
|
||||||
<div className="w-full max-w-none px-4 py-8">
|
<div className="w-full max-w-none px-4 py-8">
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="flex flex-col items-center justify-center py-8">
|
<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">
|
<Button onClick={() => refetch()} variant="outline">
|
||||||
<RefreshCw className="mr-2 h-4 w-4" />
|
<RefreshCw className="mr-2 h-4 w-4" />
|
||||||
다시 시도
|
다시 시도
|
||||||
|
|
@ -145,13 +145,13 @@ export default function TemplatesManagePage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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="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 className="flex items-center justify-between bg-white rounded-lg shadow-sm border p-6">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold text-gray-900">템플릿 관리</h1>
|
<h1 className="text-3xl font-bold text-foreground">템플릿 관리</h1>
|
||||||
<p className="mt-2 text-gray-600">화면 디자이너에서 사용할 템플릿을 관리합니다</p>
|
<p className="mt-2 text-muted-foreground">화면 디자이너에서 사용할 템플릿을 관리합니다</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex space-x-2">
|
<div className="flex space-x-2">
|
||||||
<Button asChild className="shadow-sm">
|
<Button asChild className="shadow-sm">
|
||||||
|
|
@ -164,9 +164,9 @@ export default function TemplatesManagePage() {
|
||||||
|
|
||||||
{/* 필터 및 검색 */}
|
{/* 필터 및 검색 */}
|
||||||
<Card className="shadow-sm">
|
<Card className="shadow-sm">
|
||||||
<CardHeader className="bg-gray-50/50">
|
<CardHeader className="bg-muted/50">
|
||||||
<CardTitle className="flex items-center">
|
<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>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
@ -176,7 +176,7 @@ export default function TemplatesManagePage() {
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium">검색</label>
|
<label className="text-sm font-medium">검색</label>
|
||||||
<div className="relative">
|
<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
|
<Input
|
||||||
placeholder="템플릿명, 설명 검색..."
|
placeholder="템플릿명, 설명 검색..."
|
||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
|
|
@ -232,7 +232,7 @@ export default function TemplatesManagePage() {
|
||||||
|
|
||||||
{/* 템플릿 목록 테이블 */}
|
{/* 템플릿 목록 테이블 */}
|
||||||
<Card className="shadow-sm">
|
<Card className="shadow-sm">
|
||||||
<CardHeader className="bg-gray-50/50">
|
<CardHeader className="bg-muted/50">
|
||||||
<CardTitle>템플릿 목록 ({filteredAndSortedTemplates.length}개)</CardTitle>
|
<CardTitle>템플릿 목록 ({filteredAndSortedTemplates.length}개)</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
|
|
@ -293,7 +293,7 @@ export default function TemplatesManagePage() {
|
||||||
</TableRow>
|
</TableRow>
|
||||||
) : filteredAndSortedTemplates.length === 0 ? (
|
) : filteredAndSortedTemplates.length === 0 ? (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={11} className="py-8 text-center text-gray-500">
|
<TableCell colSpan={11} className="py-8 text-center text-muted-foreground">
|
||||||
검색 조건에 맞는 템플릿이 없습니다.
|
검색 조건에 맞는 템플릿이 없습니다.
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
|
|
@ -357,7 +357,7 @@ export default function TemplatesManagePage() {
|
||||||
</Button>
|
</Button>
|
||||||
<AlertDialog>
|
<AlertDialog>
|
||||||
<AlertDialogTrigger asChild>
|
<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" />
|
<Trash2 className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</AlertDialogTrigger>
|
</AlertDialogTrigger>
|
||||||
|
|
@ -373,7 +373,7 @@ export default function TemplatesManagePage() {
|
||||||
<AlertDialogCancel>취소</AlertDialogCancel>
|
<AlertDialogCancel>취소</AlertDialogCancel>
|
||||||
<AlertDialogAction
|
<AlertDialogAction
|
||||||
onClick={() => handleDelete(template.template_code, template.template_name)}
|
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}
|
disabled={isDeleting}
|
||||||
>
|
>
|
||||||
삭제
|
삭제
|
||||||
|
|
|
||||||
|
|
@ -25,27 +25,27 @@ export default function TestPage() {
|
||||||
<h1 className="mb-4 text-2xl font-bold">토큰 테스트 페이지</h1>
|
<h1 className="mb-4 text-2xl font-bold">토큰 테스트 페이지</h1>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<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>
|
<h2 className="mb-2 font-semibold">토큰 상태</h2>
|
||||||
<p>토큰 존재: {tokenInfo.hasToken ? "✅ 예" : "❌ 아니오"}</p>
|
<p>토큰 존재: {tokenInfo.hasToken ? "✅ 예" : "❌ 아니오"}</p>
|
||||||
<p>토큰 길이: {tokenInfo.tokenLength}</p>
|
<p>토큰 길이: {tokenInfo.tokenLength}</p>
|
||||||
<p>토큰 시작: {tokenInfo.tokenStart}</p>
|
<p>토큰 시작: {tokenInfo.tokenStart}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded bg-blue-100 p-4">
|
<div className="rounded bg-primary/10 p-4">
|
||||||
<h2 className="mb-2 font-semibold">페이지 정보</h2>
|
<h2 className="mb-2 font-semibold">페이지 정보</h2>
|
||||||
<p>현재 URL: {tokenInfo.currentUrl}</p>
|
<p>현재 URL: {tokenInfo.currentUrl}</p>
|
||||||
<p>시간: {tokenInfo.timestamp}</p>
|
<p>시간: {tokenInfo.timestamp}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded bg-yellow-100 p-4">
|
<div className="rounded bg-amber-100 p-4">
|
||||||
<h2 className="mb-2 font-semibold">테스트 버튼</h2>
|
<h2 className="mb-2 font-semibold">테스트 버튼</h2>
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const token = localStorage.getItem("authToken");
|
const token = localStorage.getItem("authToken");
|
||||||
alert(`토큰: ${token ? "존재" : "없음"}\n길이: ${token ? token.length : 0}`);
|
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>
|
||||||
|
|
|
||||||
|
|
@ -31,12 +31,12 @@ export default function TokenTestPage() {
|
||||||
<h1 className="mb-4 text-2xl font-bold">토큰 상태 테스트</h1>
|
<h1 className="mb-4 text-2xl font-bold">토큰 상태 테스트</h1>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<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>
|
<h2 className="mb-2 font-semibold">토큰 정보</h2>
|
||||||
<pre className="text-sm">{JSON.stringify(tokenInfo, null, 2)}</pre>
|
<pre className="text-sm">{JSON.stringify(tokenInfo, null, 2)}</pre>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded bg-blue-100 p-4">
|
<div className="rounded bg-primary/10 p-4">
|
||||||
<h2 className="mb-2 font-semibold">테스트 버튼</h2>
|
<h2 className="mb-2 font-semibold">테스트 버튼</h2>
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
|
@ -44,7 +44,7 @@ export default function TokenTestPage() {
|
||||||
console.log("현재 토큰:", token);
|
console.log("현재 토큰:", token);
|
||||||
alert(`토큰: ${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>
|
</button>
|
||||||
|
|
|
||||||
|
|
@ -259,7 +259,7 @@ export default function RoleDetailPage({ params }: { params: Promise<{ id: strin
|
||||||
</div>
|
</div>
|
||||||
<span
|
<span
|
||||||
className={`rounded-full px-3 py-1 text-sm font-medium ${
|
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" ? "활성" : "비활성"}
|
{roleGroup.status === "active" ? "활성" : "비활성"}
|
||||||
|
|
|
||||||
|
|
@ -273,7 +273,7 @@ export default function RolesPage() {
|
||||||
</div>
|
</div>
|
||||||
<span
|
<span
|
||||||
className={`rounded-full px-2 py-1 text-xs font-medium ${
|
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" ? "활성" : "비활성"}
|
{role.status === "active" ? "활성" : "비활성"}
|
||||||
|
|
|
||||||
|
|
@ -388,7 +388,7 @@ export default function ValidationDemoPage() {
|
||||||
<CardDescription>실시간 검증이 적용된 폼입니다. 입력하면서 검증 결과를 확인해보세요.</CardDescription>
|
<CardDescription>실시간 검증이 적용된 폼입니다. 입력하면서 검증 결과를 확인해보세요.</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<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
|
<EnhancedInteractiveScreenViewer
|
||||||
component={TEST_COMPONENTS[0]} // container
|
component={TEST_COMPONENTS[0]} // container
|
||||||
allComponents={TEST_COMPONENTS}
|
allComponents={TEST_COMPONENTS}
|
||||||
|
|
@ -485,7 +485,7 @@ export default function ValidationDemoPage() {
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<h4 className="font-semibold">폼 데이터</h4>
|
<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)}
|
{JSON.stringify(formData, null, 2)}
|
||||||
</pre>
|
</pre>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -493,15 +493,15 @@ export default function ValidationDemoPage() {
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<h4 className="font-semibold">검증 통계</h4>
|
<h4 className="font-semibold">검증 통계</h4>
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div className="rounded-md bg-green-50 p-3">
|
<div className="rounded-md bg-emerald-50 p-3">
|
||||||
<div className="text-lg font-bold text-green-600">
|
<div className="text-lg font-bold text-emerald-600">
|
||||||
{Object.values(validationState.fieldStates).filter((f) => f.status === "valid").length}
|
{Object.values(validationState.fieldStates).filter((f) => f.status === "valid").length}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-green-700">유효한 필드</div>
|
<div className="text-sm text-emerald-700">유효한 필드</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-md bg-red-50 p-3">
|
<div className="rounded-md bg-destructive/10 p-3">
|
||||||
<div className="text-lg font-bold text-red-600">{validationState.errors.length}</div>
|
<div className="text-lg font-bold text-destructive">{validationState.errors.length}</div>
|
||||||
<div className="text-sm text-red-700">오류 개수</div>
|
<div className="text-sm text-destructive">오류 개수</div>
|
||||||
</div>
|
</div>
|
||||||
</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 }> = {
|
const lineStatusConfig: Record<string, { label: string; icon: React.ReactNode }> = {
|
||||||
waiting: { label: "대기", icon: <Clock className="h-3 w-3 text-muted-foreground" /> },
|
waiting: { label: "대기", icon: <Clock className="h-3 w-3 text-muted-foreground" /> },
|
||||||
pending: { label: "진행 중", icon: <Clock className="h-3 w-3 text-primary" /> },
|
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" /> },
|
rejected: { label: "반려", icon: <XCircle className="h-3 w-3 text-destructive" /> },
|
||||||
skipped: { label: "건너뜀", icon: <Clock className="h-3 w-3 text-muted-foreground" /> },
|
skipped: { label: "건너뜀", icon: <Clock className="h-3 w-3 text-muted-foreground" /> },
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -107,18 +107,18 @@ export default function DashboardViewPage({ params }: DashboardViewPageProps) {
|
||||||
return (
|
return (
|
||||||
<div className="h-screen">
|
<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 className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-gray-800">{dashboard.title}</h1>
|
<h1 className="text-2xl font-bold text-foreground">{dashboard.title}</h1>
|
||||||
{dashboard.description && <p className="mt-1 text-sm text-gray-600">{dashboard.description}</p>}
|
{dashboard.description && <p className="mt-1 text-sm text-muted-foreground">{dashboard.description}</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
{/* 새로고침 버튼 *\/}
|
{/* 새로고침 버튼 *\/}
|
||||||
<button
|
<button
|
||||||
onClick={loadDashboard}
|
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="새로고침"
|
title="새로고침"
|
||||||
>
|
>
|
||||||
🔄
|
🔄
|
||||||
|
|
@ -133,7 +133,7 @@ export default function DashboardViewPage({ params }: DashboardViewPageProps) {
|
||||||
document.documentElement.requestFullscreen();
|
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="전체화면"
|
title="전체화면"
|
||||||
>
|
>
|
||||||
⛶
|
⛶
|
||||||
|
|
@ -144,7 +144,7 @@ export default function DashboardViewPage({ params }: DashboardViewPageProps) {
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
router.push(`/admin/screenMng/dashboardList?load=${resolvedParams.dashboardId}`);
|
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>
|
</button>
|
||||||
|
|
@ -152,7 +152,7 @@ export default function DashboardViewPage({ params }: DashboardViewPageProps) {
|
||||||
</div>
|
</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.createdAt).toLocaleString()}</span>
|
||||||
<span>수정: {new Date(dashboard.updatedAt).toLocaleString()}</span>
|
<span>수정: {new Date(dashboard.updatedAt).toLocaleString()}</span>
|
||||||
<span>요소: {dashboard.elements.length}개</span>
|
<span>요소: {dashboard.elements.length}개</span>
|
||||||
|
|
|
||||||
|
|
@ -1,29 +1,93 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { useRouter } from "next/navigation";
|
||||||
import { Badge } from "@/components/ui/badge";
|
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() {
|
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 (
|
return (
|
||||||
<div className="space-y-6 p-4">
|
<div className="space-y-6 p-4 sm:p-6 lg:p-8">
|
||||||
{/* Welcome Message */}
|
{/* 헤더 영역 */}
|
||||||
<Card>
|
<div className="flex flex-col gap-1">
|
||||||
<CardContent className="pt-6">
|
<h1 className="text-2xl font-bold tracking-tight sm:text-3xl">
|
||||||
<div className="space-y-6 text-center">
|
{userName}님, 좋은 하루 되세요
|
||||||
<h3 className="text-lg font-semibold">Vexplor에 오신 것을 환영합니다!</h3>
|
</h1>
|
||||||
<p className="text-muted-foreground">제품 수명 주기 관리 시스템을 통해 효율적인 업무를 시작하세요.</p>
|
<p className="text-sm text-muted-foreground">{dateStr}</p>
|
||||||
<div className="flex justify-center space-x-2">
|
</div>
|
||||||
<Badge variant="secondary">Node.js</Badge>
|
|
||||||
<Badge variant="secondary">Next.js</Badge>
|
{/* 바로가기 */}
|
||||||
<Badge variant="secondary">Shadcn/ui</Badge>
|
<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>
|
</div>
|
||||||
</CardContent>
|
<div className="flex items-center gap-3">
|
||||||
</Card>
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -385,13 +385,13 @@ export default function MultiLangPage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-end">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mb-4 flex items-center justify-between">
|
<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>
|
</div>
|
||||||
|
|
||||||
<DataTable
|
<DataTable
|
||||||
|
|
@ -417,7 +417,7 @@ export default function MultiLangPage() {
|
||||||
{languages.map((lang) => (
|
{languages.map((lang) => (
|
||||||
<div key={lang.langCode} className="rounded-lg border p-4">
|
<div key={lang.langCode} className="rounded-lg border p-4">
|
||||||
<div className="font-semibold">{lang.langName}</div>
|
<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">
|
<Badge variant={lang.isActive === "Y" ? "default" : "secondary"} className="mt-2">
|
||||||
{lang.isActive === "Y" ? "활성" : "비활성"}
|
{lang.isActive === "Y" ? "활성" : "비활성"}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
|
@ -440,7 +440,7 @@ export default function MultiLangPage() {
|
||||||
<h3 className="mb-2 font-semibold">{company.name}</h3>
|
<h3 className="mb-2 font-semibold">{company.name}</h3>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{menus.map((menu) => (
|
{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}
|
{menu.name}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,91 @@
|
||||||
export default function MainHomePage() {
|
"use client";
|
||||||
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>
|
|
||||||
|
|
||||||
<div className="flex gap-2">
|
import { useRouter } from "next/navigation";
|
||||||
<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">
|
import { useAuth } from "@/hooks/useAuth";
|
||||||
Next.js
|
import { FileCheck, Menu, Users, Bell, FileText, Layout, Server, Shield, Calendar, ArrowRight } from "lucide-react";
|
||||||
</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">
|
const quickAccessItems = [
|
||||||
Shadcn/ui
|
{ label: "결재함", icon: FileCheck, href: "/admin/approvalBox", color: "text-primary bg-primary/10" },
|
||||||
</span>
|
{ 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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -174,10 +174,10 @@ function PopScreenViewPage() {
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen w-full items-center justify-center bg-gray-100">
|
<div className="flex h-screen w-full items-center justify-center bg-muted">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<Loader2 className="mx-auto h-10 w-10 animate-spin text-blue-500" />
|
<Loader2 className="mx-auto h-10 w-10 animate-spin text-primary" />
|
||||||
<p className="mt-4 text-gray-600">POP 화면 로딩 중...</p>
|
<p className="mt-4 text-muted-foreground">POP 화면 로딩 중...</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -185,13 +185,13 @@ function PopScreenViewPage() {
|
||||||
|
|
||||||
if (error || !screen) {
|
if (error || !screen) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen w-full items-center justify-center bg-gray-100">
|
<div className="flex h-screen w-full items-center justify-center bg-muted">
|
||||||
<div className="text-center max-w-md p-6">
|
<div className="text-center max-w-md p-6">
|
||||||
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-red-100">
|
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-destructive/10">
|
||||||
<span className="text-2xl">!</span>
|
<span className="text-2xl">!</span>
|
||||||
</div>
|
</div>
|
||||||
<h2 className="mb-2 text-xl font-bold text-gray-800">화면을 찾을 수 없습니다</h2>
|
<h2 className="mb-2 text-xl font-bold text-foreground">화면을 찾을 수 없습니다</h2>
|
||||||
<p className="mb-4 text-gray-600">{error || "요청하신 POP 화면이 존재하지 않습니다."}</p>
|
<p className="mb-4 text-muted-foreground">{error || "요청하신 POP 화면이 존재하지 않습니다."}</p>
|
||||||
<Button onClick={() => router.back()} variant="outline">
|
<Button onClick={() => router.back()} variant="outline">
|
||||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
돌아가기
|
돌아가기
|
||||||
|
|
@ -205,7 +205,7 @@ function PopScreenViewPage() {
|
||||||
<ScreenPreviewProvider isPreviewMode={isPreviewMode}>
|
<ScreenPreviewProvider isPreviewMode={isPreviewMode}>
|
||||||
<ActiveTabProvider>
|
<ActiveTabProvider>
|
||||||
<TableOptionsProvider>
|
<TableOptionsProvider>
|
||||||
<div className="h-screen bg-gray-100 flex flex-col">
|
<div className="h-screen bg-muted flex flex-col">
|
||||||
{/* 상단 툴바 (프리뷰 모드에서만) */}
|
{/* 상단 툴바 (프리뷰 모드에서만) */}
|
||||||
{isPreviewMode && (
|
{isPreviewMode && (
|
||||||
<div className="sticky top-0 z-50 bg-white border-b shadow-sm">
|
<div className="sticky top-0 z-50 bg-white border-b shadow-sm">
|
||||||
|
|
@ -216,13 +216,13 @@ function PopScreenViewPage() {
|
||||||
닫기
|
닫기
|
||||||
</Button>
|
</Button>
|
||||||
<span className="text-sm font-medium">{screen.screenName}</span>
|
<span className="text-sm font-medium">{screen.screenName}</span>
|
||||||
<span className="text-xs text-gray-400">
|
<span className="text-xs text-muted-foreground/70">
|
||||||
({currentModeKey.replace("_", " ")})
|
({currentModeKey.replace("_", " ")})
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="flex items-center gap-1 bg-gray-100 rounded-lg p-1">
|
<div className="flex items-center gap-1 bg-muted rounded-lg p-1">
|
||||||
<Button
|
<Button
|
||||||
variant={deviceType === "mobile" ? "default" : "ghost"}
|
variant={deviceType === "mobile" ? "default" : "ghost"}
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|
@ -243,7 +243,7 @@ function PopScreenViewPage() {
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-1 bg-gray-100 rounded-lg p-1">
|
<div className="flex items-center gap-1 bg-muted rounded-lg p-1">
|
||||||
<Button
|
<Button
|
||||||
variant={isLandscape ? "default" : "ghost"}
|
variant={isLandscape ? "default" : "ghost"}
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|
@ -295,7 +295,7 @@ function PopScreenViewPage() {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className={`bg-white transition-all duration-300 ${isPreviewMode ? "shadow-2xl rounded-3xl overflow-auto border-8 border-gray-800" : "w-full min-h-full"}`}
|
className={`bg-white transition-all duration-300 ${isPreviewMode ? "shadow-2xl rounded-3xl overflow-auto border-8 border-foreground" : "w-full min-h-full"}`}
|
||||||
style={isPreviewMode ? {
|
style={isPreviewMode ? {
|
||||||
width: currentDevice.width,
|
width: currentDevice.width,
|
||||||
maxHeight: "80vh",
|
maxHeight: "80vh",
|
||||||
|
|
@ -333,13 +333,13 @@ function PopScreenViewPage() {
|
||||||
) : (
|
) : (
|
||||||
// 빈 화면
|
// 빈 화면
|
||||||
<div className="flex flex-col items-center justify-center min-h-[400px] p-8 text-center">
|
<div className="flex flex-col items-center justify-center min-h-[400px] p-8 text-center">
|
||||||
<div className="w-16 h-16 rounded-full bg-gray-100 flex items-center justify-center mb-4">
|
<div className="w-16 h-16 rounded-full bg-muted flex items-center justify-center mb-4">
|
||||||
<Smartphone className="h-8 w-8 text-gray-400" />
|
<Smartphone className="h-8 w-8 text-muted-foreground/70" />
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-lg font-semibold text-gray-800 mb-2">
|
<h3 className="text-lg font-semibold text-foreground mb-2">
|
||||||
화면이 비어있습니다
|
화면이 비어있습니다
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-sm text-gray-500 max-w-xs">
|
<p className="text-sm text-muted-foreground max-w-xs">
|
||||||
POP 화면 디자이너에서 컴포넌트를 추가하여 화면을 구성하세요.
|
POP 화면 디자이너에서 컴포넌트를 추가하여 화면을 구성하세요.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -65,28 +65,28 @@
|
||||||
--radius-xl: calc(var(--radius) + 4px);
|
--radius-xl: calc(var(--radius) + 4px);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ===== CSS Variables (shadcn/ui Official) ===== */
|
/* ===== CSS Variables (Vivid Blue Theme) ===== */
|
||||||
:root {
|
:root {
|
||||||
/* Light Theme Colors - HSL Format */
|
/* Light Theme Colors - HSL Format */
|
||||||
--background: 0 0% 100%;
|
--background: 0 0% 100%;
|
||||||
--foreground: 222.2 84% 4.9%;
|
--foreground: 224 71% 4%;
|
||||||
--card: 0 0% 100%;
|
--card: 0 0% 100%;
|
||||||
--card-foreground: 222.2 84% 4.9%;
|
--card-foreground: 224 71% 4%;
|
||||||
--popover: 0 0% 100%;
|
--popover: 0 0% 100%;
|
||||||
--popover-foreground: 222.2 84% 4.9%;
|
--popover-foreground: 224 71% 4%;
|
||||||
--primary: 222.2 47.4% 11.2%;
|
--primary: 217.2 91.2% 59.8%;
|
||||||
--primary-foreground: 210 40% 98%;
|
--primary-foreground: 0 0% 100%;
|
||||||
--secondary: 210 40% 96.1%;
|
--secondary: 220 14.3% 95.9%;
|
||||||
--secondary-foreground: 222.2 47.4% 11.2%;
|
--secondary-foreground: 220.9 39.3% 11%;
|
||||||
--muted: 210 40% 96.1%;
|
--muted: 220 14.3% 95.9%;
|
||||||
--muted-foreground: 215.4 16.3% 46.9%;
|
--muted-foreground: 220 8.9% 46.1%;
|
||||||
--accent: 210 40% 96.1%;
|
--accent: 220 14.3% 95.9%;
|
||||||
--accent-foreground: 222.2 47.4% 11.2%;
|
--accent-foreground: 220.9 39.3% 11%;
|
||||||
--destructive: 0 84.2% 60.2%;
|
--destructive: 0 84.2% 60.2%;
|
||||||
--destructive-foreground: 210 40% 98%;
|
--destructive-foreground: 0 0% 100%;
|
||||||
--border: 214.3 31.8% 91.4%;
|
--border: 220 13% 88%;
|
||||||
--input: 214.3 31.8% 91.4%;
|
--input: 220 13% 88%;
|
||||||
--ring: 222.2 84% 4.9%;
|
--ring: 217.2 91.2% 59.8%;
|
||||||
|
|
||||||
/* Success Colors (Emerald) */
|
/* Success Colors (Emerald) */
|
||||||
--success: 142 76% 36%;
|
--success: 142 76% 36%;
|
||||||
|
|
@ -111,66 +111,83 @@
|
||||||
--radius: 0.5rem;
|
--radius: 0.5rem;
|
||||||
|
|
||||||
/* Sidebar Colors */
|
/* Sidebar Colors */
|
||||||
--sidebar-background: 0 0% 98%;
|
--sidebar-background: 220 20% 97%;
|
||||||
--sidebar-foreground: 240 5.3% 26.1%;
|
--sidebar-foreground: 224 71% 4%;
|
||||||
--sidebar-primary: 240 5.9% 10%;
|
--sidebar-primary: 217.2 91.2% 59.8%;
|
||||||
--sidebar-primary-foreground: 0 0% 98%;
|
--sidebar-primary-foreground: 0 0% 100%;
|
||||||
--sidebar-accent: 240 4.8% 95.9%;
|
--sidebar-accent: 220 14.3% 95.9%;
|
||||||
--sidebar-accent-foreground: 240 5.9% 10%;
|
--sidebar-accent-foreground: 220.9 39.3% 11%;
|
||||||
--sidebar-border: 220 13% 91%;
|
--sidebar-border: 220 13% 91%;
|
||||||
--sidebar-ring: 217.2 91.2% 59.8%;
|
--sidebar-ring: 217.2 91.2% 59.8%;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ===== Dark Theme ===== */
|
/* ===== Dark Theme (Palantir-Inspired) ===== */
|
||||||
.dark {
|
.dark {
|
||||||
--background: 222.2 84% 4.9%;
|
/* 배경: 팔란티어 스타일 깊은 네이비 */
|
||||||
--foreground: 210 40% 98%;
|
--background: 222 47% 6%;
|
||||||
--card: 222.2 84% 4.9%;
|
--foreground: 210 20% 95%;
|
||||||
--card-foreground: 210 40% 98%;
|
|
||||||
--popover: 222.2 84% 4.9%;
|
|
||||||
--popover-foreground: 210 40% 98%;
|
|
||||||
--primary: 210 40% 98%;
|
|
||||||
--primary-foreground: 222.2 47.4% 11.2%;
|
|
||||||
--secondary: 217.2 32.6% 17.5%;
|
|
||||||
--secondary-foreground: 210 40% 98%;
|
|
||||||
--muted: 217.2 32.6% 17.5%;
|
|
||||||
--muted-foreground: 215 20.2% 65.1%;
|
|
||||||
--accent: 217.2 32.6% 17.5%;
|
|
||||||
--accent-foreground: 210 40% 98%;
|
|
||||||
--destructive: 0 62.8% 30.6%;
|
|
||||||
--destructive-foreground: 210 40% 98%;
|
|
||||||
--border: 217.2 32.6% 17.5%;
|
|
||||||
--input: 217.2 32.6% 17.5%;
|
|
||||||
--ring: 212.7 26.8% 83.9%;
|
|
||||||
|
|
||||||
/* Success Colors (Emerald) - Dark */
|
/* 카드: 배경보다 약간 밝게 (레이어 구분) */
|
||||||
--success: 142 76% 36%;
|
--card: 220 40% 9%;
|
||||||
|
--card-foreground: 210 20% 95%;
|
||||||
|
|
||||||
|
/* 팝오버: 카드와 동일 */
|
||||||
|
--popover: 220 40% 9%;
|
||||||
|
--popover-foreground: 210 20% 95%;
|
||||||
|
|
||||||
|
/* Primary: 다크 배경에서 약간 더 밝은 블루 */
|
||||||
|
--primary: 217 91% 65%;
|
||||||
|
--primary-foreground: 0 0% 100%;
|
||||||
|
|
||||||
|
/* Secondary: 어두운 슬레이트 */
|
||||||
|
--secondary: 220 25% 14%;
|
||||||
|
--secondary-foreground: 210 20% 90%;
|
||||||
|
|
||||||
|
/* Muted: 차분한 회색-네이비 */
|
||||||
|
--muted: 220 20% 13%;
|
||||||
|
--muted-foreground: 215 15% 58%;
|
||||||
|
|
||||||
|
/* Accent: secondary와 유사 */
|
||||||
|
--accent: 220 25% 16%;
|
||||||
|
--accent-foreground: 210 20% 90%;
|
||||||
|
|
||||||
|
/* Destructive: 다크에서 더 밝은 레드 */
|
||||||
|
--destructive: 0 72% 51%;
|
||||||
|
--destructive-foreground: 0 0% 100%;
|
||||||
|
|
||||||
|
/* Border: 팔란티어 스타일 - 보더가 더 잘 보임 */
|
||||||
|
--border: 220 20% 18%;
|
||||||
|
--input: 220 20% 18%;
|
||||||
|
--ring: 217 91% 65%;
|
||||||
|
|
||||||
|
/* Success (Emerald) - 다크 배경용 밝기 조정 */
|
||||||
|
--success: 142 70% 42%;
|
||||||
--success-foreground: 0 0% 100%;
|
--success-foreground: 0 0% 100%;
|
||||||
|
|
||||||
/* Warning Colors (Amber) - Dark */
|
/* Warning (Amber) - 다크 배경용 밝기 조정 */
|
||||||
--warning: 38 92% 50%;
|
--warning: 38 92% 55%;
|
||||||
--warning-foreground: 0 0% 100%;
|
--warning-foreground: 0 0% 10%;
|
||||||
|
|
||||||
/* Info Colors (Cyan) - Dark */
|
/* Info (Cyan) - 다크 배경용 밝기 조정 */
|
||||||
--info: 188 94% 43%;
|
--info: 188 90% 48%;
|
||||||
--info-foreground: 0 0% 100%;
|
--info-foreground: 0 0% 100%;
|
||||||
|
|
||||||
/* Chart Colors - Dark */
|
/* Chart Colors - 다크 배경에서 선명한 컬러 */
|
||||||
--chart-1: 220 70% 50%;
|
--chart-1: 220 70% 55%;
|
||||||
--chart-2: 160 60% 45%;
|
--chart-2: 160 60% 48%;
|
||||||
--chart-3: 30 80% 55%;
|
--chart-3: 30 80% 58%;
|
||||||
--chart-4: 280 65% 60%;
|
--chart-4: 280 65% 63%;
|
||||||
--chart-5: 340 75% 55%;
|
--chart-5: 340 75% 58%;
|
||||||
|
|
||||||
/* Sidebar Colors - Dark */
|
/* Sidebar - 메인 배경보다 약간 어둡게 */
|
||||||
--sidebar-background: 222.2 84% 4.9%;
|
--sidebar-background: 222 47% 5%;
|
||||||
--sidebar-foreground: 210 40% 98%;
|
--sidebar-foreground: 210 20% 90%;
|
||||||
--sidebar-primary: 217.2 91.2% 59.8%;
|
--sidebar-primary: 217 91% 65%;
|
||||||
--sidebar-primary-foreground: 222.2 47.4% 11.2%;
|
--sidebar-primary-foreground: 0 0% 100%;
|
||||||
--sidebar-accent: 217.2 32.6% 17.5%;
|
--sidebar-accent: 220 25% 14%;
|
||||||
--sidebar-accent-foreground: 210 40% 98%;
|
--sidebar-accent-foreground: 210 20% 90%;
|
||||||
--sidebar-border: 217.2 32.6% 17.5%;
|
--sidebar-border: 220 20% 16%;
|
||||||
--sidebar-ring: 217.2 91.2% 59.8%;
|
--sidebar-ring: 217 91% 65%;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ===== Base Styles ===== */
|
/* ===== Base Styles ===== */
|
||||||
|
|
@ -459,3 +476,166 @@ select {
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ===== End of Global Styles ===== */
|
/* ===== End of Global Styles ===== */
|
||||||
|
|
||||||
|
/* ===== Dark Mode Compatibility Layer ===== */
|
||||||
|
/* 하드코딩된 Tailwind 색상 → 다크 모드 자동 변환 */
|
||||||
|
/* 개별 컴포넌트 수정 없이, 이 한 곳에서 전체 프로젝트 커버 */
|
||||||
|
|
||||||
|
/* --- 1. 배경색: white/gray → 시맨틱 토큰 --- */
|
||||||
|
.dark .bg-white { background-color: hsl(var(--card)) !important; }
|
||||||
|
.dark .bg-gray-50 { background-color: hsl(var(--muted)) !important; }
|
||||||
|
.dark .bg-gray-100 { background-color: hsl(var(--muted)) !important; }
|
||||||
|
.dark .bg-gray-200 { background-color: hsl(var(--accent)) !important; }
|
||||||
|
|
||||||
|
/* --- 2. 텍스트: gray → 시맨틱 토큰 --- */
|
||||||
|
.dark .text-gray-400 { color: hsl(var(--muted-foreground)) !important; }
|
||||||
|
.dark .text-gray-500 { color: hsl(var(--muted-foreground)) !important; }
|
||||||
|
.dark .text-gray-600 { color: hsl(var(--foreground) / 0.7) !important; }
|
||||||
|
.dark .text-gray-700 { color: hsl(var(--foreground) / 0.8) !important; }
|
||||||
|
.dark .text-gray-800 { color: hsl(var(--foreground) / 0.9) !important; }
|
||||||
|
.dark .text-gray-900 { color: hsl(var(--foreground)) !important; }
|
||||||
|
|
||||||
|
/* --- 3. 보더: gray → 시맨틱 토큰 --- */
|
||||||
|
.dark .border-gray-100 { border-color: hsl(var(--border)) !important; }
|
||||||
|
.dark .border-gray-200 { border-color: hsl(var(--border)) !important; }
|
||||||
|
.dark .border-gray-300 { border-color: hsl(var(--border)) !important; }
|
||||||
|
.dark .divide-gray-200 > * + * { border-color: hsl(var(--border)) !important; }
|
||||||
|
|
||||||
|
/* --- 4. 호버: gray → 시맨틱 토큰 --- */
|
||||||
|
.dark .hover\:bg-gray-50:hover { background-color: hsl(var(--muted)) !important; }
|
||||||
|
.dark .hover\:bg-gray-100:hover { background-color: hsl(var(--accent)) !important; }
|
||||||
|
.dark .hover\:bg-gray-200:hover { background-color: hsl(var(--accent)) !important; }
|
||||||
|
|
||||||
|
/* --- 5. Emerald (성공/완료 상태) --- */
|
||||||
|
.dark .bg-emerald-50 { background-color: hsl(142 40% 12%) !important; }
|
||||||
|
.dark .bg-emerald-100 { background-color: hsl(142 40% 15%) !important; }
|
||||||
|
.dark .text-emerald-600 { color: hsl(142 70% 55%) !important; }
|
||||||
|
.dark .text-emerald-700 { color: hsl(142 70% 50%) !important; }
|
||||||
|
.dark .text-emerald-800 { color: hsl(142 70% 60%) !important; }
|
||||||
|
.dark .text-emerald-900 { color: hsl(142 70% 65%) !important; }
|
||||||
|
.dark .border-emerald-200 { border-color: hsl(142 40% 25%) !important; }
|
||||||
|
.dark .border-emerald-300 { border-color: hsl(142 40% 30%) !important; }
|
||||||
|
.dark .ring-emerald-200 { --tw-ring-color: hsl(142 40% 25%) !important; }
|
||||||
|
|
||||||
|
/* --- 6. Amber/Yellow (경고/주의 상태) --- */
|
||||||
|
.dark .bg-amber-50 { background-color: hsl(38 40% 12%) !important; }
|
||||||
|
.dark .bg-amber-100 { background-color: hsl(38 40% 15%) !important; }
|
||||||
|
.dark .bg-yellow-50 { background-color: hsl(48 40% 12%) !important; }
|
||||||
|
.dark .bg-yellow-100 { background-color: hsl(48 40% 15%) !important; }
|
||||||
|
.dark .text-amber-600 { color: hsl(38 90% 58%) !important; }
|
||||||
|
.dark .text-amber-700 { color: hsl(38 90% 55%) !important; }
|
||||||
|
.dark .text-amber-800 { color: hsl(38 90% 60%) !important; }
|
||||||
|
.dark .text-amber-900 { color: hsl(38 90% 65%) !important; }
|
||||||
|
.dark .text-yellow-600 { color: hsl(48 90% 55%) !important; }
|
||||||
|
.dark .text-yellow-700 { color: hsl(48 90% 50%) !important; }
|
||||||
|
.dark .text-yellow-800 { color: hsl(48 90% 60%) !important; }
|
||||||
|
.dark .border-amber-200 { border-color: hsl(38 40% 25%) !important; }
|
||||||
|
.dark .border-amber-300 { border-color: hsl(38 40% 30%) !important; }
|
||||||
|
.dark .border-yellow-200 { border-color: hsl(48 40% 25%) !important; }
|
||||||
|
.dark .ring-amber-200 { --tw-ring-color: hsl(38 40% 25%) !important; }
|
||||||
|
|
||||||
|
/* --- 7. Blue (정보/프라이머리 상태) --- */
|
||||||
|
.dark .bg-blue-50 { background-color: hsl(217 40% 12%) !important; }
|
||||||
|
.dark .bg-blue-100 { background-color: hsl(217 40% 15%) !important; }
|
||||||
|
.dark .text-blue-600 { color: hsl(217 90% 65%) !important; }
|
||||||
|
.dark .text-blue-700 { color: hsl(217 90% 60%) !important; }
|
||||||
|
.dark .text-blue-800 { color: hsl(217 90% 65%) !important; }
|
||||||
|
.dark .border-blue-200 { border-color: hsl(217 40% 25%) !important; }
|
||||||
|
.dark .ring-blue-200 { --tw-ring-color: hsl(217 40% 25%) !important; }
|
||||||
|
|
||||||
|
/* --- 8. Red (에러/삭제 상태) --- */
|
||||||
|
.dark .bg-red-50 { background-color: hsl(0 40% 12%) !important; }
|
||||||
|
.dark .bg-red-100 { background-color: hsl(0 40% 15%) !important; }
|
||||||
|
.dark .text-red-600 { color: hsl(0 75% 60%) !important; }
|
||||||
|
.dark .text-red-700 { color: hsl(0 75% 55%) !important; }
|
||||||
|
.dark .text-red-800 { color: hsl(0 75% 60%) !important; }
|
||||||
|
.dark .border-red-200 { border-color: hsl(0 40% 25%) !important; }
|
||||||
|
.dark .ring-red-200 { --tw-ring-color: hsl(0 40% 25%) !important; }
|
||||||
|
|
||||||
|
/* --- 9. Green (성공 - emerald 변형) --- */
|
||||||
|
.dark .bg-green-50 { background-color: hsl(142 40% 12%) !important; }
|
||||||
|
.dark .bg-green-100 { background-color: hsl(142 40% 15%) !important; }
|
||||||
|
.dark .text-green-600 { color: hsl(142 70% 55%) !important; }
|
||||||
|
.dark .text-green-700 { color: hsl(142 70% 50%) !important; }
|
||||||
|
.dark .border-green-200 { border-color: hsl(142 40% 25%) !important; }
|
||||||
|
|
||||||
|
/* --- 10. Slate (gray 변형) --- */
|
||||||
|
.dark .bg-slate-50 { background-color: hsl(var(--muted)) !important; }
|
||||||
|
.dark .bg-slate-100 { background-color: hsl(var(--muted)) !important; }
|
||||||
|
.dark .text-slate-500 { color: hsl(var(--muted-foreground)) !important; }
|
||||||
|
.dark .text-slate-600 { color: hsl(var(--foreground) / 0.7) !important; }
|
||||||
|
.dark .text-slate-700 { color: hsl(var(--foreground) / 0.8) !important; }
|
||||||
|
.dark .border-slate-200 { border-color: hsl(var(--border)) !important; }
|
||||||
|
|
||||||
|
/* --- 11. bg-white opacity 변형 --- */
|
||||||
|
.dark .bg-white\/30 { background-color: hsl(var(--card) / 0.3) !important; }
|
||||||
|
.dark .bg-white\/50 { background-color: hsl(var(--card) / 0.5) !important; }
|
||||||
|
.dark .bg-white\/80 { background-color: hsl(var(--card) / 0.8) !important; }
|
||||||
|
.dark .bg-white\/90 { background-color: hsl(var(--card) / 0.9) !important; }
|
||||||
|
.dark .hover\:bg-white:hover { background-color: hsl(var(--card)) !important; }
|
||||||
|
|
||||||
|
/* --- 12. text-black → foreground --- */
|
||||||
|
.dark .text-black { color: hsl(var(--foreground)) !important; }
|
||||||
|
|
||||||
|
/* --- 13. bg/text/border - purple (관리 UI) --- */
|
||||||
|
.dark .bg-purple-50 { background-color: hsl(270 40% 12%) !important; }
|
||||||
|
.dark .bg-purple-100 { background-color: hsl(270 40% 15%) !important; }
|
||||||
|
.dark .bg-purple-200 { background-color: hsl(270 40% 20%) !important; }
|
||||||
|
.dark .text-purple-500 { color: hsl(270 70% 60%) !important; }
|
||||||
|
.dark .text-purple-600 { color: hsl(270 70% 55%) !important; }
|
||||||
|
.dark .text-purple-700 { color: hsl(270 70% 50%) !important; }
|
||||||
|
.dark .border-purple-200 { border-color: hsl(270 40% 25%) !important; }
|
||||||
|
.dark .border-purple-300 { border-color: hsl(270 40% 30%) !important; }
|
||||||
|
|
||||||
|
/* --- 14. bg/text/border - indigo --- */
|
||||||
|
.dark .bg-indigo-50 { background-color: hsl(231 40% 12%) !important; }
|
||||||
|
.dark .bg-indigo-100 { background-color: hsl(231 40% 15%) !important; }
|
||||||
|
.dark .text-indigo-600 { color: hsl(231 70% 60%) !important; }
|
||||||
|
.dark .text-indigo-700 { color: hsl(231 70% 55%) !important; }
|
||||||
|
.dark .border-indigo-200 { border-color: hsl(231 40% 25%) !important; }
|
||||||
|
|
||||||
|
/* --- 15. bg/text - pink/rose (상태 뱃지) --- */
|
||||||
|
.dark .bg-pink-50 { background-color: hsl(330 40% 12%) !important; }
|
||||||
|
.dark .bg-pink-100 { background-color: hsl(330 40% 15%) !important; }
|
||||||
|
.dark .text-pink-600 { color: hsl(330 70% 60%) !important; }
|
||||||
|
.dark .text-pink-700 { color: hsl(330 70% 55%) !important; }
|
||||||
|
.dark .bg-rose-50 { background-color: hsl(350 40% 12%) !important; }
|
||||||
|
.dark .bg-rose-100 { background-color: hsl(350 40% 15%) !important; }
|
||||||
|
.dark .text-rose-600 { color: hsl(350 70% 60%) !important; }
|
||||||
|
.dark .text-rose-700 { color: hsl(350 70% 55%) !important; }
|
||||||
|
|
||||||
|
/* --- 16. bg/text - cyan/teal (정보/상태) --- */
|
||||||
|
.dark .bg-cyan-50 { background-color: hsl(187 40% 12%) !important; }
|
||||||
|
.dark .bg-cyan-100 { background-color: hsl(187 40% 15%) !important; }
|
||||||
|
.dark .text-cyan-600 { color: hsl(187 70% 55%) !important; }
|
||||||
|
.dark .text-cyan-700 { color: hsl(187 70% 50%) !important; }
|
||||||
|
.dark .bg-teal-50 { background-color: hsl(162 40% 12%) !important; }
|
||||||
|
.dark .bg-teal-100 { background-color: hsl(162 40% 15%) !important; }
|
||||||
|
.dark .text-teal-600 { color: hsl(162 70% 55%) !important; }
|
||||||
|
.dark .text-teal-700 { color: hsl(162 70% 50%) !important; }
|
||||||
|
|
||||||
|
/* --- 17. bg/text - orange (경고 변형) --- */
|
||||||
|
.dark .bg-orange-50 { background-color: hsl(25 40% 12%) !important; }
|
||||||
|
.dark .bg-orange-100 { background-color: hsl(25 40% 15%) !important; }
|
||||||
|
.dark .bg-orange-200 { background-color: hsl(25 40% 20%) !important; }
|
||||||
|
.dark .text-orange-600 { color: hsl(25 90% 65%) !important; }
|
||||||
|
.dark .text-orange-700 { color: hsl(25 90% 70%) !important; }
|
||||||
|
.dark .border-orange-200 { border-color: hsl(25 40% 25%) !important; }
|
||||||
|
.dark .border-orange-300 { border-color: hsl(25 40% 30%) !important; }
|
||||||
|
|
||||||
|
/* --- 18. bg/text/border - violet (필터/관계 표시) --- */
|
||||||
|
.dark .bg-violet-50 { background-color: hsl(263 40% 12%) !important; }
|
||||||
|
.dark .bg-violet-100 { background-color: hsl(263 40% 18%) !important; }
|
||||||
|
.dark .bg-violet-200 { background-color: hsl(263 40% 22%) !important; }
|
||||||
|
.dark .text-violet-500 { color: hsl(263 80% 70%) !important; }
|
||||||
|
.dark .text-violet-600 { color: hsl(263 80% 65%) !important; }
|
||||||
|
.dark .text-violet-700 { color: hsl(263 80% 72%) !important; }
|
||||||
|
.dark .border-violet-200 { border-color: hsl(263 40% 30%) !important; }
|
||||||
|
.dark .border-violet-300 { border-color: hsl(263 40% 35%) !important; }
|
||||||
|
|
||||||
|
/* --- 19. bg/text/border - amber (조인/경고) --- */
|
||||||
|
.dark .bg-amber-200 { background-color: hsl(38 40% 20%) !important; }
|
||||||
|
.dark .text-amber-500 { color: hsl(38 90% 60%) !important; }
|
||||||
|
.dark .text-amber-600 { color: hsl(38 90% 65%) !important; }
|
||||||
|
|
||||||
|
/* ===== End Dark Mode Compatibility Layer ===== */
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
import type { Metadata, Viewport } from "next";
|
import type { Metadata, Viewport } from "next";
|
||||||
import { Inter, JetBrains_Mono } from "next/font/google";
|
import { Inter, JetBrains_Mono } from "next/font/google";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
import { ThemeProvider } from "@/components/providers/ThemeProvider";
|
||||||
import { QueryProvider } from "@/providers/QueryProvider";
|
import { QueryProvider } from "@/providers/QueryProvider";
|
||||||
import { RegistryProvider } from "./registry-provider";
|
import { RegistryProvider } from "./registry-provider";
|
||||||
import { Toaster } from "sonner";
|
import { Toaster } from "sonner";
|
||||||
|
|
||||||
|
|
||||||
const inter = Inter({
|
const inter = Inter({
|
||||||
subsets: ["latin"],
|
subsets: ["latin"],
|
||||||
variable: "--font-inter",
|
variable: "--font-inter",
|
||||||
|
|
@ -39,13 +39,15 @@ export default function RootLayout({
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}>) {
|
}>) {
|
||||||
return (
|
return (
|
||||||
<html lang="ko" className="h-full">
|
<html lang="ko" className="h-full" suppressHydrationWarning>
|
||||||
<body className={`${inter.variable} ${jetbrainsMono.variable} h-full bg-white font-sans antialiased`}>
|
<body className={`${inter.variable} ${jetbrainsMono.variable} bg-background h-full font-sans antialiased`}>
|
||||||
<div id="root" className="h-full">
|
<div id="root" className="h-full">
|
||||||
<QueryProvider>
|
<ThemeProvider>
|
||||||
<RegistryProvider>{children}</RegistryProvider>
|
<QueryProvider>
|
||||||
<Toaster position="top-right" />
|
<RegistryProvider>{children}</RegistryProvider>
|
||||||
</QueryProvider>
|
<Toaster position="top-right" />
|
||||||
|
</QueryProvider>
|
||||||
|
</ThemeProvider>
|
||||||
{/* Portal 컨테이너 */}
|
{/* Portal 컨테이너 */}
|
||||||
<div id="portal-root" data-radix-portal="true" />
|
<div id="portal-root" data-radix-portal="true" />
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -227,9 +227,9 @@ export default function SimpleTypeSafetyTest() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{passedTests === totalTests && totalTests > 0 && (
|
{passedTests === totalTests && totalTests > 0 && (
|
||||||
<div className="mt-4 rounded-lg border border-green-200 bg-green-50 p-4">
|
<div className="mt-4 rounded-lg border border-emerald-200 bg-emerald-50 p-4">
|
||||||
<div className="font-medium text-green-800">🎉 모든 타입 안전성 테스트가 통과되었습니다!</div>
|
<div className="font-medium text-emerald-800">🎉 모든 타입 안전성 테스트가 통과되었습니다!</div>
|
||||||
<div className="mt-2 text-sm text-green-600">
|
<div className="mt-2 text-sm text-emerald-600">
|
||||||
화면관리, 제어관리, 테이블타입관리 시스템이 안전하게 작동합니다.
|
화면관리, 제어관리, 테이블타입관리 시스템이 안전하게 작동합니다.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -138,7 +138,7 @@ export default function StressTestPage() {
|
||||||
<div className="space-y-4 text-center">
|
<div className="space-y-4 text-center">
|
||||||
<h1 className="text-3xl font-bold">🔥 타입 시스템 스트레스 테스트</h1>
|
<h1 className="text-3xl font-bold">🔥 타입 시스템 스트레스 테스트</h1>
|
||||||
<p className="text-muted-foreground">극한 상황에서 타입 시스템의 견고함과 성능을 검증합니다</p>
|
<p className="text-muted-foreground">극한 상황에서 타입 시스템의 견고함과 성능을 검증합니다</p>
|
||||||
<div className="rounded-lg bg-orange-50 p-3 text-sm text-orange-600">
|
<div className="rounded-lg bg-amber-50 p-3 text-sm text-amber-600">
|
||||||
⚠️ 주의: 이 테스트는 시스템에 높은 부하를 가할 수 있습니다
|
⚠️ 주의: 이 테스트는 시스템에 높은 부하를 가할 수 있습니다
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -184,19 +184,19 @@ export default function StressTestPage() {
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
|
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="text-2xl font-bold text-green-600">{testResults.passedTests}</div>
|
<div className="text-2xl font-bold text-emerald-600">{testResults.passedTests}</div>
|
||||||
<div className="text-muted-foreground text-sm">통과</div>
|
<div className="text-muted-foreground text-sm">통과</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="text-2xl font-bold text-red-600">{testResults.failedTests}</div>
|
<div className="text-2xl font-bold text-destructive">{testResults.failedTests}</div>
|
||||||
<div className="text-muted-foreground text-sm">실패</div>
|
<div className="text-muted-foreground text-sm">실패</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="text-2xl font-bold text-yellow-600">{testResults.warningTests}</div>
|
<div className="text-2xl font-bold text-amber-600">{testResults.warningTests}</div>
|
||||||
<div className="text-muted-foreground text-sm">경고</div>
|
<div className="text-muted-foreground text-sm">경고</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="text-2xl font-bold text-blue-600">{Math.round(testResults.totalDuration)}ms</div>
|
<div className="text-2xl font-bold text-primary">{Math.round(testResults.totalDuration)}ms</div>
|
||||||
<div className="text-muted-foreground text-sm">총 소요시간</div>
|
<div className="text-muted-foreground text-sm">총 소요시간</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -212,16 +212,16 @@ export default function StressTestPage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{testResults.success ? (
|
{testResults.success ? (
|
||||||
<div className="rounded-lg border border-green-200 bg-green-50 p-4">
|
<div className="rounded-lg border border-emerald-200 bg-emerald-50 p-4">
|
||||||
<div className="font-medium text-green-800">🎉 모든 스트레스 테스트 통과!</div>
|
<div className="font-medium text-emerald-800">🎉 모든 스트레스 테스트 통과!</div>
|
||||||
<div className="mt-2 text-sm text-green-600">
|
<div className="mt-2 text-sm text-emerald-600">
|
||||||
타입 시스템이 극한 상황에서도 안정적으로 작동합니다.
|
타입 시스템이 극한 상황에서도 안정적으로 작동합니다.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="rounded-lg border border-red-200 bg-red-50 p-4">
|
<div className="rounded-lg border border-destructive/20 bg-destructive/10 p-4">
|
||||||
<div className="font-medium text-red-800">⚠️ 일부 스트레스 테스트 실패</div>
|
<div className="font-medium text-red-800">⚠️ 일부 스트레스 테스트 실패</div>
|
||||||
<div className="mt-2 text-sm text-red-600">
|
<div className="mt-2 text-sm text-destructive">
|
||||||
개선이 필요한 영역이 있습니다. 아래 권장사항을 확인하세요.
|
개선이 필요한 영역이 있습니다. 아래 권장사항을 확인하세요.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -253,7 +253,7 @@ export default function StressTestPage() {
|
||||||
|
|
||||||
{/* 메트릭스 표시 */}
|
{/* 메트릭스 표시 */}
|
||||||
{result.metrics && (
|
{result.metrics && (
|
||||||
<div className="mt-3 rounded bg-gray-50 p-3 text-xs">
|
<div className="mt-3 rounded bg-muted p-3 text-xs">
|
||||||
<div className="mb-1 font-medium">📊 상세 메트릭스:</div>
|
<div className="mb-1 font-medium">📊 상세 메트릭스:</div>
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
{Object.entries(result.metrics).map(([key, value]) => (
|
{Object.entries(result.metrics).map(([key, value]) => (
|
||||||
|
|
@ -286,7 +286,7 @@ export default function StressTestPage() {
|
||||||
<ul className="space-y-2">
|
<ul className="space-y-2">
|
||||||
{testResults.recommendation.map((rec, index) => (
|
{testResults.recommendation.map((rec, index) => (
|
||||||
<li key={index} className="flex items-start gap-2">
|
<li key={index} className="flex items-start gap-2">
|
||||||
<span className="mt-0.5 text-blue-500">•</span>
|
<span className="mt-0.5 text-primary">•</span>
|
||||||
<span className="text-sm">{rec}</span>
|
<span className="text-sm">{rec}</span>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
|
|
@ -304,7 +304,7 @@ export default function StressTestPage() {
|
||||||
<CardTitle className="text-lg">📋 테스트 로그 (최근 10개)</CardTitle>
|
<CardTitle className="text-lg">📋 테스트 로그 (최근 10개)</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="h-40 overflow-y-auto rounded bg-black p-4 font-mono text-xs text-green-400">
|
<div className="h-40 overflow-y-auto rounded bg-black p-4 font-mono text-xs text-emerald-400">
|
||||||
{testLogs.slice(-10).map((log, index) => (
|
{testLogs.slice(-10).map((log, index) => (
|
||||||
<div key={index}>{log}</div>
|
<div key={index}>{log}</div>
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
|
|
@ -66,10 +66,10 @@ export const GlobalFileViewer: React.FC<GlobalFileViewerProps> = ({
|
||||||
return <Video {...iconProps} className="text-purple-600" />;
|
return <Video {...iconProps} className="text-purple-600" />;
|
||||||
}
|
}
|
||||||
if (['mp3', 'wav', 'flac', 'aac', 'ogg'].includes(extension)) {
|
if (['mp3', 'wav', 'flac', 'aac', 'ogg'].includes(extension)) {
|
||||||
return <Music {...iconProps} className="text-green-600" />;
|
return <Music {...iconProps} className="text-emerald-600" />;
|
||||||
}
|
}
|
||||||
if (['zip', 'rar', '7z', 'tar', 'gz'].includes(extension)) {
|
if (['zip', 'rar', '7z', 'tar', 'gz'].includes(extension)) {
|
||||||
return <Archive {...iconProps} className="text-yellow-600" />;
|
return <Archive {...iconProps} className="text-amber-600" />;
|
||||||
}
|
}
|
||||||
if (['txt', 'md', 'doc', 'docx', 'pdf', 'rtf'].includes(extension)) {
|
if (['txt', 'md', 'doc', 'docx', 'pdf', 'rtf'].includes(extension)) {
|
||||||
return <FileText {...iconProps} className="text-destructive" />;
|
return <FileText {...iconProps} className="text-destructive" />;
|
||||||
|
|
@ -192,7 +192,7 @@ export const GlobalFileViewer: React.FC<GlobalFileViewerProps> = ({
|
||||||
{showControls && (
|
{showControls && (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="relative flex-1">
|
<div className="relative flex-1">
|
||||||
<Search className="absolute left-2 top-1/2 transform -translate-y-1/2 w-4 h-4 text-gray-400" />
|
<Search className="absolute left-2 top-1/2 transform -translate-y-1/2 w-4 h-4 text-muted-foreground/70" />
|
||||||
<Input
|
<Input
|
||||||
placeholder="파일명으로 검색..."
|
placeholder="파일명으로 검색..."
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
|
|
@ -219,7 +219,7 @@ export const GlobalFileViewer: React.FC<GlobalFileViewerProps> = ({
|
||||||
style={{ maxHeight }}
|
style={{ maxHeight }}
|
||||||
>
|
>
|
||||||
{filteredFiles.length === 0 ? (
|
{filteredFiles.length === 0 ? (
|
||||||
<div className="text-center py-8 text-gray-500">
|
<div className="text-center py-8 text-muted-foreground">
|
||||||
{searchQuery ? "검색 결과가 없습니다." : "저장된 파일이 없습니다."}
|
{searchQuery ? "검색 결과가 없습니다." : "저장된 파일이 없습니다."}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -232,7 +232,7 @@ export const GlobalFileViewer: React.FC<GlobalFileViewerProps> = ({
|
||||||
<div className="font-medium truncate">
|
<div className="font-medium truncate">
|
||||||
{file.realFileName || file.savedFileName}
|
{file.realFileName || file.savedFileName}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-gray-500 flex items-center gap-2">
|
<div className="text-sm text-muted-foreground flex items-center gap-2">
|
||||||
<span>{formatFileSize(file.fileSize)}</span>
|
<span>{formatFileSize(file.fileSize)}</span>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<Clock className="w-3 h-3" />
|
<Clock className="w-3 h-3" />
|
||||||
|
|
@ -273,7 +273,7 @@ export const GlobalFileViewer: React.FC<GlobalFileViewerProps> = ({
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => handleRemove(file)}
|
onClick={() => handleRemove(file)}
|
||||||
className="flex items-center gap-1 text-destructive hover:text-red-700"
|
className="flex items-center gap-1 text-destructive hover:text-destructive"
|
||||||
>
|
>
|
||||||
<Trash2 className="w-3 h-3" />
|
<Trash2 className="w-3 h-3" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
|
||||||
|
|
@ -225,7 +225,7 @@ export function AddColumnModal({ isOpen, onClose, tableName, onSuccess }: AddCol
|
||||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="columnName">
|
<Label htmlFor="columnName">
|
||||||
컬럼명 <span className="text-red-500">*</span>
|
컬럼명 <span className="text-destructive">*</span>
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="columnName"
|
id="columnName"
|
||||||
|
|
@ -233,7 +233,7 @@ export function AddColumnModal({ isOpen, onClose, tableName, onSuccess }: AddCol
|
||||||
onChange={(e) => updateColumn({ name: e.target.value })}
|
onChange={(e) => updateColumn({ name: e.target.value })}
|
||||||
placeholder="column_name"
|
placeholder="column_name"
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
className={validationErrors.some((e) => e.includes("컬럼명")) ? "border-red-300" : ""}
|
className={validationErrors.some((e) => e.includes("컬럼명")) ? "border-destructive/30" : ""}
|
||||||
/>
|
/>
|
||||||
<p className="text-muted-foreground text-xs">영문자로 시작, 영문자/숫자/언더스코어만 사용 가능</p>
|
<p className="text-muted-foreground text-xs">영문자로 시작, 영문자/숫자/언더스코어만 사용 가능</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -255,7 +255,7 @@ export function AddColumnModal({ isOpen, onClose, tableName, onSuccess }: AddCol
|
||||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>
|
<Label>
|
||||||
입력타입 <span className="text-red-500">*</span>
|
입력타입 <span className="text-destructive">*</span>
|
||||||
</Label>
|
</Label>
|
||||||
<Select value={column.inputType} onValueChange={handleInputTypeChange} disabled={loading}>
|
<Select value={column.inputType} onValueChange={handleInputTypeChange} disabled={loading}>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
|
|
@ -354,7 +354,7 @@ export function AddColumnModal({ isOpen, onClose, tableName, onSuccess }: AddCol
|
||||||
<Button
|
<Button
|
||||||
onClick={handleAddColumn}
|
onClick={handleAddColumn}
|
||||||
disabled={!isFormValid || loading}
|
disabled={!isFormValid || loading}
|
||||||
className="bg-blue-600 hover:bg-blue-700"
|
className="bg-primary hover:bg-primary/90"
|
||||||
>
|
>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<>
|
<>
|
||||||
|
|
|
||||||
|
|
@ -49,7 +49,7 @@ export function AuthenticationConfig({
|
||||||
|
|
||||||
{/* 인증 타입별 설정 필드 */}
|
{/* 인증 타입별 설정 필드 */}
|
||||||
{authType === "api-key" && (
|
{authType === "api-key" && (
|
||||||
<div className="space-y-4 rounded-md border bg-gray-50 p-4">
|
<div className="space-y-4 rounded-md border bg-muted p-4">
|
||||||
<h4 className="text-sm font-medium">API Key 설정</h4>
|
<h4 className="text-sm font-medium">API Key 설정</h4>
|
||||||
|
|
||||||
{/* 키 위치 */}
|
{/* 키 위치 */}
|
||||||
|
|
@ -96,7 +96,7 @@ export function AuthenticationConfig({
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{authType === "bearer" && (
|
{authType === "bearer" && (
|
||||||
<div className="space-y-4 rounded-md border bg-gray-50 p-4">
|
<div className="space-y-4 rounded-md border bg-muted p-4">
|
||||||
<h4 className="text-sm font-medium">Bearer Token 설정</h4>
|
<h4 className="text-sm font-medium">Bearer Token 설정</h4>
|
||||||
|
|
||||||
{/* 토큰 */}
|
{/* 토큰 */}
|
||||||
|
|
@ -111,14 +111,14 @@ export function AuthenticationConfig({
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-xs text-gray-500">
|
<p className="text-xs text-muted-foreground">
|
||||||
* Authorization 헤더에 "Bearer {token}" 형식으로 전송됩니다.
|
* Authorization 헤더에 "Bearer {token}" 형식으로 전송됩니다.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{authType === "basic" && (
|
{authType === "basic" && (
|
||||||
<div className="space-y-4 rounded-md border bg-gray-50 p-4">
|
<div className="space-y-4 rounded-md border bg-muted p-4">
|
||||||
<h4 className="text-sm font-medium">Basic Auth 설정</h4>
|
<h4 className="text-sm font-medium">Basic Auth 설정</h4>
|
||||||
|
|
||||||
{/* 사용자명 */}
|
{/* 사용자명 */}
|
||||||
|
|
@ -145,12 +145,12 @@ export function AuthenticationConfig({
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-xs text-gray-500">* Authorization 헤더에 Base64 인코딩된 인증 정보가 전송됩니다.</p>
|
<p className="text-xs text-muted-foreground">* Authorization 헤더에 Base64 인코딩된 인증 정보가 전송됩니다.</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{authType === "oauth2" && (
|
{authType === "oauth2" && (
|
||||||
<div className="space-y-4 rounded-md border bg-gray-50 p-4">
|
<div className="space-y-4 rounded-md border bg-muted p-4">
|
||||||
<h4 className="text-sm font-medium">OAuth 2.0 설정</h4>
|
<h4 className="text-sm font-medium">OAuth 2.0 설정</h4>
|
||||||
|
|
||||||
{/* Client ID */}
|
{/* Client ID */}
|
||||||
|
|
@ -189,12 +189,12 @@ export function AuthenticationConfig({
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-xs text-gray-500">* OAuth 2.0 Client Credentials Grant 방식을 사용합니다.</p>
|
<p className="text-xs text-muted-foreground">* OAuth 2.0 Client Credentials Grant 방식을 사용합니다.</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{authType === "db-token" && (
|
{authType === "db-token" && (
|
||||||
<div className="space-y-4 rounded-md border bg-gray-50 p-4">
|
<div className="space-y-4 rounded-md border bg-muted p-4">
|
||||||
<h4 className="text-sm font-medium">DB 기반 토큰 설정</h4>
|
<h4 className="text-sm font-medium">DB 기반 토큰 설정</h4>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
|
@ -275,14 +275,14 @@ export function AuthenticationConfig({
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-xs text-gray-500">
|
<p className="text-xs text-muted-foreground">
|
||||||
company_code는 현재 로그인한 사용자의 회사 코드로 자동 필터링됩니다.
|
company_code는 현재 로그인한 사용자의 회사 코드로 자동 필터링됩니다.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{authType === "none" && (
|
{authType === "none" && (
|
||||||
<div className="rounded-md border border-dashed p-4 text-center text-sm text-gray-500">
|
<div className="rounded-md border border-dashed p-4 text-center text-sm text-muted-foreground">
|
||||||
인증이 필요하지 않은 공개 API입니다.
|
인증이 필요하지 않은 공개 API입니다.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -132,7 +132,7 @@ export function CompanySwitcher({ onClose, isOpen = false }: CompanySwitcherProp
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* 현재 회사 정보 */}
|
{/* 현재 회사 정보 */}
|
||||||
<div className="rounded-lg border bg-gradient-to-r from-primary/10 to-primary/5 p-4">
|
<div className="rounded-lg border bg-muted/40 p-4">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/20">
|
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/20">
|
||||||
<Building2 className="h-5 w-5 text-primary" />
|
<Building2 className="h-5 w-5 text-primary" />
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,7 @@
|
||||||
import { Edit, Trash2, HardDrive, FileText, Users } from "lucide-react";
|
import { Edit, Trash2, HardDrive, FileText, Users } from "lucide-react";
|
||||||
import { Company } from "@/types/company";
|
import { Company } from "@/types/company";
|
||||||
import { COMPANY_TABLE_COLUMNS } from "@/constants/company";
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
import { ResponsiveDataView, RDVColumn, RDVCardField } from "@/components/common/ResponsiveDataView";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
interface CompanyTableProps {
|
interface CompanyTableProps {
|
||||||
|
|
@ -14,8 +13,6 @@ interface CompanyTableProps {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 회사 목록 테이블 컴포넌트
|
* 회사 목록 테이블 컴포넌트
|
||||||
* 데스크톱: 테이블 뷰
|
|
||||||
* 모바일/태블릿: 카드 뷰
|
|
||||||
*/
|
*/
|
||||||
export function CompanyTable({ companies, isLoading, onEdit, onDelete }: CompanyTableProps) {
|
export function CompanyTable({ companies, isLoading, onEdit, onDelete }: CompanyTableProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
@ -52,206 +49,88 @@ export function CompanyTable({ companies, isLoading, onEdit, onDelete }: Company
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 로딩 상태 렌더링
|
// 데스크톱 테이블 컬럼 정의
|
||||||
if (isLoading) {
|
const columns: RDVColumn<Company>[] = [
|
||||||
return (
|
{
|
||||||
<>
|
key: "company_code",
|
||||||
{/* 데스크톱 테이블 스켈레톤 */}
|
label: "회사코드",
|
||||||
<div className="bg-card hidden shadow-sm lg:block">
|
width: "150px",
|
||||||
<Table>
|
render: (value) => <span className="font-mono">{value}</span>,
|
||||||
<TableHeader>
|
},
|
||||||
<TableRow>
|
{
|
||||||
{COMPANY_TABLE_COLUMNS.map((column) => (
|
key: "company_name",
|
||||||
<TableHead key={column.key} className="h-12 px-6 py-3 text-sm font-semibold">
|
label: "회사명",
|
||||||
{column.label}
|
render: (value) => <span className="font-medium">{value}</span>,
|
||||||
</TableHead>
|
},
|
||||||
))}
|
{
|
||||||
<TableHead className="h-12 px-6 py-3 text-sm font-semibold">디스크 사용량</TableHead>
|
key: "writer",
|
||||||
<TableHead className="h-12 px-6 py-3 text-sm font-semibold">작업</TableHead>
|
label: "등록자",
|
||||||
</TableRow>
|
width: "200px",
|
||||||
</TableHeader>
|
},
|
||||||
<TableBody>
|
{
|
||||||
{Array.from({ length: 10 }).map((_, index) => (
|
key: "diskUsage",
|
||||||
<TableRow key={index}>
|
label: "디스크 사용량",
|
||||||
<TableCell className="h-16">
|
hideOnMobile: true,
|
||||||
<div className="bg-muted h-4 animate-pulse rounded"></div>
|
render: (_value, row) => formatDiskUsage(row),
|
||||||
</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 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="flex gap-2">
|
|
||||||
<div className="bg-muted h-8 w-8 animate-pulse rounded"></div>
|
|
||||||
<div className="bg-muted h-8 w-8 animate-pulse rounded"></div>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 모바일/태블릿 카드 스켈레톤 */}
|
// 모바일 카드 필드 정의
|
||||||
<div className="grid gap-4 sm:grid-cols-2 lg:hidden">
|
const cardFields: RDVCardField<Company>[] = [
|
||||||
{Array.from({ length: 6 }).map((_, index) => (
|
{
|
||||||
<div key={index} className="bg-card rounded-lg border p-4 shadow-sm">
|
label: "작성자",
|
||||||
<div className="mb-4 flex items-start justify-between">
|
render: (company) => <span className="font-medium">{company.writer}</span>,
|
||||||
<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>
|
label: "디스크 사용량",
|
||||||
</div>
|
render: (company) => formatDiskUsage(company),
|
||||||
</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>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 데이터가 없을 때
|
|
||||||
if (companies.length === 0) {
|
|
||||||
return (
|
|
||||||
<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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 실제 데이터 렌더링
|
|
||||||
return (
|
return (
|
||||||
<>
|
<ResponsiveDataView<Company>
|
||||||
{/* 데스크톱 테이블 뷰 (lg 이상) */}
|
data={companies}
|
||||||
<div className="bg-card hidden lg:block">
|
columns={columns}
|
||||||
<Table>
|
keyExtractor={(c) => c.regdate + c.company_code}
|
||||||
<TableHeader>
|
isLoading={isLoading}
|
||||||
<TableRow>
|
emptyMessage="등록된 회사가 없습니다."
|
||||||
{COMPANY_TABLE_COLUMNS.map((column) => (
|
skeletonCount={10}
|
||||||
<TableHead key={column.key} className="h-12 px-6 py-3 text-sm font-semibold">
|
cardTitle={(c) => c.company_name}
|
||||||
{column.label}
|
cardSubtitle={(c) => <span className="font-mono">{c.company_code}</span>}
|
||||||
</TableHead>
|
cardFields={cardFields}
|
||||||
))}
|
actionsLabel="작업"
|
||||||
<TableHead className="h-12 px-6 py-3 text-sm font-semibold">디스크 사용량</TableHead>
|
actionsWidth="180px"
|
||||||
<TableHead className="h-12 px-6 py-3 text-sm font-semibold">작업</TableHead>
|
renderActions={(company) => (
|
||||||
</TableRow>
|
<>
|
||||||
</TableHeader>
|
<Button
|
||||||
<TableBody>
|
variant="ghost"
|
||||||
{companies.map((company) => (
|
size="icon"
|
||||||
<TableRow
|
onClick={() => handleManageDepartments(company)}
|
||||||
key={company.regdate + company.company_code}
|
className="h-8 w-8"
|
||||||
className="bg-background hover:bg-muted/50 transition-colors"
|
aria-label="부서관리"
|
||||||
>
|
|
||||||
<TableCell className="h-16 px-6 py-3 font-mono text-sm">{company.company_code}</TableCell>
|
|
||||||
<TableCell className="h-16 px-6 py-3 text-sm font-medium">{company.company_name}</TableCell>
|
|
||||||
<TableCell className="h-16 px-6 py-3 text-sm">{company.writer}</TableCell>
|
|
||||||
<TableCell className="h-16 px-6 py-3">{formatDiskUsage(company)}</TableCell>
|
|
||||||
<TableCell className="h-16 px-6 py-3">
|
|
||||||
<div className="flex gap-2">
|
|
||||||
{/* <Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
onClick={() => handleManageDepartments(company)}
|
|
||||||
className="h-8 w-8"
|
|
||||||
aria-label="부서관리"
|
|
||||||
>
|
|
||||||
<Users className="h-4 w-4" />
|
|
||||||
</Button> */}
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
onClick={() => onEdit(company)}
|
|
||||||
className="h-8 w-8"
|
|
||||||
aria-label="수정"
|
|
||||||
>
|
|
||||||
<Edit className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
onClick={() => onDelete(company)}
|
|
||||||
className="text-destructive hover:bg-destructive/10 hover:text-destructive h-8 w-8"
|
|
||||||
aria-label="삭제"
|
|
||||||
>
|
|
||||||
<Trash2 className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 모바일/태블릿 카드 뷰 (lg 미만) */}
|
|
||||||
<div className="grid gap-4 sm:grid-cols-2 lg:hidden">
|
|
||||||
{companies.map((company) => (
|
|
||||||
<div
|
|
||||||
key={company.regdate + company.company_code}
|
|
||||||
className="bg-card hover:bg-muted/50 rounded-lg border p-4 shadow-sm transition-colors"
|
|
||||||
>
|
>
|
||||||
{/* 헤더 */}
|
<Users className="h-4 w-4" />
|
||||||
<div className="mb-4 flex items-start justify-between">
|
</Button>
|
||||||
<div className="flex-1">
|
<Button
|
||||||
<h3 className="text-base font-semibold">{company.company_name}</h3>
|
variant="ghost"
|
||||||
<p className="text-muted-foreground mt-1 font-mono text-sm">{company.company_code}</p>
|
size="icon"
|
||||||
</div>
|
onClick={() => onEdit(company)}
|
||||||
</div>
|
className="h-8 w-8"
|
||||||
|
aria-label="수정"
|
||||||
{/* 정보 */}
|
>
|
||||||
<div className="space-y-2 border-t pt-4">
|
<Edit className="h-4 w-4" />
|
||||||
<div className="flex justify-between text-sm">
|
</Button>
|
||||||
<span className="text-muted-foreground">작성자</span>
|
<Button
|
||||||
<span className="font-medium">{company.writer}</span>
|
variant="ghost"
|
||||||
</div>
|
size="icon"
|
||||||
<div className="flex justify-between text-sm">
|
onClick={() => onDelete(company)}
|
||||||
<span className="text-muted-foreground">디스크 사용량</span>
|
className="text-destructive hover:bg-destructive/10 hover:text-destructive h-8 w-8"
|
||||||
<div>{formatDiskUsage(company)}</div>
|
aria-label="삭제"
|
||||||
</div>
|
>
|
||||||
</div>
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
{/* 액션 */}
|
</>
|
||||||
<div className="mt-4 flex gap-2 border-t pt-4">
|
)}
|
||||||
<Button
|
/>
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => handleManageDepartments(company)}
|
|
||||||
className="h-9 flex-1 gap-2 text-sm"
|
|
||||||
>
|
|
||||||
<Users className="h-4 w-4" />
|
|
||||||
부서
|
|
||||||
</Button>
|
|
||||||
<Button variant="outline" size="sm" onClick={() => onEdit(company)} className="h-9 flex-1 gap-2 text-sm">
|
|
||||||
<Edit className="h-4 w-4" />
|
|
||||||
수정
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => onDelete(company)}
|
|
||||||
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>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -341,14 +341,14 @@ export function CreateTableModal({
|
||||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="tableName">
|
<Label htmlFor="tableName">
|
||||||
테이블명 <span className="text-red-500">*</span>
|
테이블명 <span className="text-destructive">*</span>
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="tableName"
|
id="tableName"
|
||||||
value={tableName}
|
value={tableName}
|
||||||
onChange={(e) => handleTableNameChange(e.target.value)}
|
onChange={(e) => handleTableNameChange(e.target.value)}
|
||||||
placeholder="예: customer_info"
|
placeholder="예: customer_info"
|
||||||
className={tableNameError ? "border-red-300" : ""}
|
className={tableNameError ? "border-destructive/30" : ""}
|
||||||
/>
|
/>
|
||||||
{tableNameError && <p className="text-destructive text-sm">{tableNameError}</p>}
|
{tableNameError && <p className="text-destructive text-sm">{tableNameError}</p>}
|
||||||
<p className="text-muted-foreground text-xs">영문자로 시작, 영문자/숫자/언더스코어만 사용 가능</p>
|
<p className="text-muted-foreground text-xs">영문자로 시작, 영문자/숫자/언더스코어만 사용 가능</p>
|
||||||
|
|
@ -369,7 +369,7 @@ export function CreateTableModal({
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Label>
|
<Label>
|
||||||
컬럼 정의 <span className="text-red-500">*</span>
|
컬럼 정의 <span className="text-destructive">*</span>
|
||||||
</Label>
|
</Label>
|
||||||
<Button type="button" variant="outline" size="sm" onClick={addColumn} disabled={loading}>
|
<Button type="button" variant="outline" size="sm" onClick={addColumn} disabled={loading}>
|
||||||
<Plus className="mr-1 h-4 w-4" />
|
<Plus className="mr-1 h-4 w-4" />
|
||||||
|
|
@ -440,7 +440,7 @@ export function CreateTableModal({
|
||||||
<div className="font-medium">경고:</div>
|
<div className="font-medium">경고:</div>
|
||||||
<ul className="list-inside list-disc space-y-1">
|
<ul className="list-inside list-disc space-y-1">
|
||||||
{validationResult.warnings.map((warning: string, index: number) => (
|
{validationResult.warnings.map((warning: string, index: number) => (
|
||||||
<li key={index} className="text-sm text-orange-600">
|
<li key={index} className="text-sm text-amber-600">
|
||||||
{warning}
|
{warning}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
|
|
@ -471,7 +471,7 @@ export function CreateTableModal({
|
||||||
<Button
|
<Button
|
||||||
onClick={handleCreateTable}
|
onClick={handleCreateTable}
|
||||||
disabled={!isFormValid || loading || (validationResult && !validationResult.isValid)}
|
disabled={!isFormValid || loading || (validationResult && !validationResult.isValid)}
|
||||||
className="bg-green-600 hover:bg-green-700"
|
className="bg-emerald-600 hover:bg-green-700"
|
||||||
>
|
>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<>
|
<>
|
||||||
|
|
|
||||||
|
|
@ -276,11 +276,11 @@ export function DDLLogViewer({ isOpen, onClose }: DDLLogViewerProps) {
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{log.success ? (
|
{log.success ? (
|
||||||
<CheckCircle2 className="h-4 w-4 text-green-600" />
|
<CheckCircle2 className="h-4 w-4 text-emerald-600" />
|
||||||
) : (
|
) : (
|
||||||
<XCircle className="h-4 w-4 text-destructive" />
|
<XCircle className="h-4 w-4 text-destructive" />
|
||||||
)}
|
)}
|
||||||
<span className={log.success ? "text-green-600" : "text-destructive"}>
|
<span className={log.success ? "text-emerald-600" : "text-destructive"}>
|
||||||
{log.success ? "성공" : "실패"}
|
{log.success ? "성공" : "실패"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -323,7 +323,7 @@ export function DDLLogViewer({ isOpen, onClose }: DDLLogViewerProps) {
|
||||||
<CardTitle className="text-sm font-medium">성공</CardTitle>
|
<CardTitle className="text-sm font-medium">성공</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="text-2xl font-bold text-green-600">{statistics.successfulExecutions}</div>
|
<div className="text-2xl font-bold text-emerald-600">{statistics.successfulExecutions}</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -484,7 +484,7 @@ export const ExternalDbConnectionModal: React.FC<ExternalDbConnectionModalProps>
|
||||||
<div
|
<div
|
||||||
className={`rounded-md border p-3 text-sm ${
|
className={`rounded-md border p-3 text-sm ${
|
||||||
testResult.success
|
testResult.success
|
||||||
? "border-green-200 bg-green-50 text-green-800"
|
? "border-emerald-200 bg-emerald-50 text-emerald-800"
|
||||||
: "border-destructive/20 bg-destructive/10 text-red-800"
|
: "border-destructive/20 bg-destructive/10 text-red-800"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
|
|
@ -527,7 +527,7 @@ export const ExternalDbConnectionModal: React.FC<ExternalDbConnectionModalProps>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{showAdvanced && (
|
{showAdvanced && (
|
||||||
<div className="space-y-4 border-l-2 border-gray-200 pl-6">
|
<div className="space-y-4 border-l-2 border-border pl-6">
|
||||||
<div className="grid grid-cols-3 gap-4">
|
<div className="grid grid-cols-3 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="connection_timeout">연결 타임아웃 (초)</Label>
|
<Label htmlFor="connection_timeout">연결 타임아웃 (초)</Label>
|
||||||
|
|
|
||||||
|
|
@ -116,7 +116,7 @@ export function HeadersManager({ headers, onChange }: HeadersManagerProps) {
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => removeHeader(index)}
|
onClick={() => removeHeader(index)}
|
||||||
className="h-8 w-8 p-0 text-red-600 hover:bg-red-50 hover:text-red-700"
|
className="h-8 w-8 p-0 text-destructive hover:bg-destructive/10 hover:text-destructive"
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4" />
|
<Trash2 className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -127,12 +127,12 @@ export function HeadersManager({ headers, onChange }: HeadersManagerProps) {
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="rounded-md border border-dashed p-4 text-center text-sm text-gray-500">
|
<div className="rounded-md border border-dashed p-4 text-center text-sm text-muted-foreground">
|
||||||
헤더가 없습니다. 헤더 추가 버튼을 클릭하여 추가하세요.
|
헤더가 없습니다. 헤더 추가 버튼을 클릭하여 추가하세요.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<p className="text-xs text-gray-500">
|
<p className="text-xs text-muted-foreground">
|
||||||
* 공통으로 사용할 HTTP 헤더를 설정합니다. 인증 헤더는 별도의 인증 설정에서 관리됩니다.
|
* 공통으로 사용할 HTTP 헤더를 설정합니다. 인증 헤더는 별도의 인증 설정에서 관리됩니다.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -239,30 +239,30 @@ export const LayoutFormModal: React.FC<LayoutFormModalProps> = ({ open, onOpenCh
|
||||||
<div className="mb-6 flex items-center justify-center">
|
<div className="mb-6 flex items-center justify-center">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<div
|
<div
|
||||||
className={`flex items-center gap-2 ${step === "basic" ? "text-primary" : step === "template" || step === "advanced" ? "text-green-600" : "text-gray-400"}`}
|
className={`flex items-center gap-2 ${step === "basic" ? "text-primary" : step === "template" || step === "advanced" ? "text-emerald-600" : "text-muted-foreground/70"}`}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={`flex h-8 w-8 items-center justify-center rounded-full text-sm font-medium ${step === "basic" ? "bg-primary/20 text-primary" : step === "template" || step === "advanced" ? "bg-green-100 text-green-600" : "bg-gray-100"}`}
|
className={`flex h-8 w-8 items-center justify-center rounded-full text-sm font-medium ${step === "basic" ? "bg-primary/20 text-primary" : step === "template" || step === "advanced" ? "bg-emerald-100 text-emerald-600" : "bg-muted"}`}
|
||||||
>
|
>
|
||||||
1
|
1
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm font-medium">기본 정보</span>
|
<span className="text-sm font-medium">기본 정보</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="h-px w-8 bg-gray-300" />
|
<div className="h-px w-8 bg-muted/60" />
|
||||||
<div
|
<div
|
||||||
className={`flex items-center gap-2 ${step === "template" ? "text-primary" : step === "advanced" ? "text-green-600" : "text-gray-400"}`}
|
className={`flex items-center gap-2 ${step === "template" ? "text-primary" : step === "advanced" ? "text-emerald-600" : "text-muted-foreground/70"}`}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={`flex h-8 w-8 items-center justify-center rounded-full text-sm font-medium ${step === "template" ? "bg-primary/20 text-primary" : step === "advanced" ? "bg-green-100 text-green-600" : "bg-gray-100"}`}
|
className={`flex h-8 w-8 items-center justify-center rounded-full text-sm font-medium ${step === "template" ? "bg-primary/20 text-primary" : step === "advanced" ? "bg-emerald-100 text-emerald-600" : "bg-muted"}`}
|
||||||
>
|
>
|
||||||
2
|
2
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm font-medium">템플릿 선택</span>
|
<span className="text-sm font-medium">템플릿 선택</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="h-px w-8 bg-gray-300" />
|
<div className="h-px w-8 bg-muted/60" />
|
||||||
<div className={`flex items-center gap-2 ${step === "advanced" ? "text-primary" : "text-gray-400"}`}>
|
<div className={`flex items-center gap-2 ${step === "advanced" ? "text-primary" : "text-muted-foreground/70"}`}>
|
||||||
<div
|
<div
|
||||||
className={`flex h-8 w-8 items-center justify-center rounded-full text-sm font-medium ${step === "advanced" ? "bg-primary/20 text-primary" : "bg-gray-100"}`}
|
className={`flex h-8 w-8 items-center justify-center rounded-full text-sm font-medium ${step === "advanced" ? "bg-primary/20 text-primary" : "bg-muted"}`}
|
||||||
>
|
>
|
||||||
3
|
3
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -305,7 +305,7 @@ export const LayoutFormModal: React.FC<LayoutFormModalProps> = ({ open, onOpenCh
|
||||||
<Card
|
<Card
|
||||||
key={category.id}
|
key={category.id}
|
||||||
className={`cursor-pointer transition-all ${
|
className={`cursor-pointer transition-all ${
|
||||||
formData.category === category.id ? "bg-accent ring-2 ring-blue-500" : "hover:bg-gray-50"
|
formData.category === category.id ? "bg-accent ring-2 ring-ring" : "hover:bg-muted"
|
||||||
}`}
|
}`}
|
||||||
onClick={() => setFormData((prev) => ({ ...prev, category: category.id }))}
|
onClick={() => setFormData((prev) => ({ ...prev, category: category.id }))}
|
||||||
>
|
>
|
||||||
|
|
@ -314,7 +314,7 @@ export const LayoutFormModal: React.FC<LayoutFormModalProps> = ({ open, onOpenCh
|
||||||
<IconComponent className="h-5 w-5 text-muted-foreground" />
|
<IconComponent className="h-5 w-5 text-muted-foreground" />
|
||||||
<div>
|
<div>
|
||||||
<div className="font-medium">{category.name}</div>
|
<div className="font-medium">{category.name}</div>
|
||||||
<div className="text-xs text-gray-500">{category.description}</div>
|
<div className="text-xs text-muted-foreground">{category.description}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|
@ -341,13 +341,13 @@ export const LayoutFormModal: React.FC<LayoutFormModalProps> = ({ open, onOpenCh
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<Label>레이아웃 템플릿 *</Label>
|
<Label>레이아웃 템플릿 *</Label>
|
||||||
<p className="mb-3 text-sm text-gray-500">원하는 레이아웃 구조를 선택하세요</p>
|
<p className="mb-3 text-sm text-muted-foreground">원하는 레이아웃 구조를 선택하세요</p>
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
{LAYOUT_TEMPLATES.map((template) => (
|
{LAYOUT_TEMPLATES.map((template) => (
|
||||||
<Card
|
<Card
|
||||||
key={template.id}
|
key={template.id}
|
||||||
className={`cursor-pointer transition-all ${
|
className={`cursor-pointer transition-all ${
|
||||||
formData.template === template.id ? "bg-accent ring-2 ring-blue-500" : "hover:bg-gray-50"
|
formData.template === template.id ? "bg-accent ring-2 ring-ring" : "hover:bg-muted"
|
||||||
}`}
|
}`}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
setFormData((prev) => ({
|
setFormData((prev) => ({
|
||||||
|
|
@ -364,8 +364,8 @@ export const LayoutFormModal: React.FC<LayoutFormModalProps> = ({ open, onOpenCh
|
||||||
<Badge variant="secondary">{template.zones}개 영역</Badge>
|
<Badge variant="secondary">{template.zones}개 영역</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-muted-foreground">{template.description}</div>
|
<div className="text-sm text-muted-foreground">{template.description}</div>
|
||||||
<div className="text-xs text-gray-500">예: {template.example}</div>
|
<div className="text-xs text-muted-foreground">예: {template.example}</div>
|
||||||
<div className="rounded bg-gray-100 p-2 text-center font-mono text-xs">{template.icon}</div>
|
<div className="rounded bg-muted p-2 text-center font-mono text-xs">{template.icon}</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
@ -418,7 +418,7 @@ export const LayoutFormModal: React.FC<LayoutFormModalProps> = ({ open, onOpenCh
|
||||||
>
|
>
|
||||||
<Plus className="h-4 w-4" />
|
<Plus className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<span className="text-sm text-gray-500">개 영역</span>
|
<span className="text-sm text-muted-foreground">개 영역</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -428,17 +428,17 @@ export const LayoutFormModal: React.FC<LayoutFormModalProps> = ({ open, onOpenCh
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{generationResult ? (
|
{generationResult ? (
|
||||||
<Alert
|
<Alert
|
||||||
className={generationResult.success ? "border-green-200 bg-green-50" : "border-destructive/20 bg-destructive/10"}
|
className={generationResult.success ? "border-emerald-200 bg-emerald-50" : "border-destructive/20 bg-destructive/10"}
|
||||||
>
|
>
|
||||||
<Info className="h-4 w-4" />
|
<Info className="h-4 w-4" />
|
||||||
<AlertDescription className={generationResult.success ? "text-green-800" : "text-red-800"}>
|
<AlertDescription className={generationResult.success ? "text-emerald-800" : "text-red-800"}>
|
||||||
{generationResult.message}
|
{generationResult.message}
|
||||||
{generationResult.success && generationResult.files && (
|
{generationResult.success && generationResult.files && (
|
||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
<div className="text-sm font-medium">생성된 파일들:</div>
|
<div className="text-sm font-medium">생성된 파일들:</div>
|
||||||
<ul className="mt-1 space-y-1 text-xs">
|
<ul className="mt-1 space-y-1 text-xs">
|
||||||
{generationResult.files.map((file, index) => (
|
{generationResult.files.map((file, index) => (
|
||||||
<li key={index} className="text-green-700">
|
<li key={index} className="text-emerald-700">
|
||||||
• {file}
|
• {file}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
|
|
@ -748,7 +748,7 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
{!isEdit && level !== 1 && (
|
{!isEdit && level !== 1 && (
|
||||||
<p className="text-xs text-gray-500">{getText(MENU_MANAGEMENT_KEYS.FORM_COMPANY_SUBMENU_NOTE)}</p>
|
<p className="text-xs text-muted-foreground">{getText(MENU_MANAGEMENT_KEYS.FORM_COMPANY_SUBMENU_NOTE)}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -817,7 +817,7 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="font-medium">{key.langKey}</div>
|
<div className="font-medium">{key.langKey}</div>
|
||||||
{key.description && <div className="text-xs text-gray-500">{key.description}</div>}
|
{key.description && <div className="text-xs text-muted-foreground">{key.description}</div>}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -825,7 +825,7 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{selectedLangKeyInfo && (
|
{selectedLangKeyInfo && (
|
||||||
<p className="text-xs text-gray-500">
|
<p className="text-xs text-muted-foreground">
|
||||||
{getText(MENU_MANAGEMENT_KEYS.FORM_LANG_KEY_SELECTED)
|
{getText(MENU_MANAGEMENT_KEYS.FORM_LANG_KEY_SELECTED)
|
||||||
.replace("{key}", selectedLangKeyInfo.langKey)
|
.replace("{key}", selectedLangKeyInfo.langKey)
|
||||||
.replace("{description}", selectedLangKeyInfo.description)}
|
.replace("{description}", selectedLangKeyInfo.description)}
|
||||||
|
|
@ -896,7 +896,7 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
|
||||||
{/* 검색 입력 */}
|
{/* 검색 입력 */}
|
||||||
<div className="sticky top-0 border-b bg-white p-2">
|
<div className="sticky top-0 border-b bg-white p-2">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Search className="absolute top-1/2 left-2 h-4 w-4 -translate-y-1/2 text-gray-400" />
|
<Search className="absolute top-1/2 left-2 h-4 w-4 -translate-y-1/2 text-muted-foreground/70" />
|
||||||
<Input
|
<Input
|
||||||
placeholder="화면 검색..."
|
placeholder="화면 검색..."
|
||||||
value={screenSearchText}
|
value={screenSearchText}
|
||||||
|
|
@ -918,14 +918,14 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
|
||||||
<div
|
<div
|
||||||
key={`screen-${screen.screenId || screen.id || index}-${screen.screenCode || index}`}
|
key={`screen-${screen.screenId || screen.id || index}-${screen.screenCode || index}`}
|
||||||
onClick={() => handleScreenSelect(screen)}
|
onClick={() => handleScreenSelect(screen)}
|
||||||
className="cursor-pointer border-b px-3 py-2 last:border-b-0 hover:bg-gray-100"
|
className="cursor-pointer border-b px-3 py-2 last:border-b-0 hover:bg-muted"
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm font-medium">{screen.screenName}</div>
|
<div className="text-sm font-medium">{screen.screenName}</div>
|
||||||
<div className="text-xs text-gray-500">{screen.screenCode}</div>
|
<div className="text-xs text-muted-foreground">{screen.screenCode}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-gray-400">ID: {screen.screenId || screen.id || "N/A"}</div>
|
<div className="text-xs text-muted-foreground/70">ID: {screen.screenId || screen.id || "N/A"}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
@ -933,7 +933,7 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
|
||||||
(screen) =>
|
(screen) =>
|
||||||
screen.screenName.toLowerCase().includes(screenSearchText.toLowerCase()) ||
|
screen.screenName.toLowerCase().includes(screenSearchText.toLowerCase()) ||
|
||||||
screen.screenCode.toLowerCase().includes(screenSearchText.toLowerCase()),
|
screen.screenCode.toLowerCase().includes(screenSearchText.toLowerCase()),
|
||||||
).length === 0 && <div className="px-3 py-2 text-sm text-gray-500">검색 결과가 없습니다.</div>}
|
).length === 0 && <div className="px-3 py-2 text-sm text-muted-foreground">검색 결과가 없습니다.</div>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -942,7 +942,7 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
|
||||||
{/* 선택된 화면 정보 표시 */}
|
{/* 선택된 화면 정보 표시 */}
|
||||||
{selectedScreen && (
|
{selectedScreen && (
|
||||||
<div className="bg-accent rounded-md border p-3">
|
<div className="bg-accent rounded-md border p-3">
|
||||||
<div className="text-sm font-medium text-blue-900">{selectedScreen.screenName}</div>
|
<div className="text-sm font-medium text-primary">{selectedScreen.screenName}</div>
|
||||||
<div className="text-primary text-xs">코드: {selectedScreen.screenCode}</div>
|
<div className="text-primary text-xs">코드: {selectedScreen.screenCode}</div>
|
||||||
<div className="text-primary text-xs">생성된 URL: {formData.menuUrl}</div>
|
<div className="text-primary text-xs">생성된 URL: {formData.menuUrl}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -971,7 +971,7 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
|
||||||
{/* 검색창 */}
|
{/* 검색창 */}
|
||||||
<div className="border-b p-2">
|
<div className="border-b p-2">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Search className="absolute top-2.5 left-2 h-4 w-4 text-gray-400" />
|
<Search className="absolute top-2.5 left-2 h-4 w-4 text-muted-foreground/70" />
|
||||||
<Input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="대시보드 검색..."
|
placeholder="대시보드 검색..."
|
||||||
|
|
@ -995,13 +995,13 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
|
||||||
<div
|
<div
|
||||||
key={dashboard.id}
|
key={dashboard.id}
|
||||||
onClick={() => handleDashboardSelect(dashboard)}
|
onClick={() => handleDashboardSelect(dashboard)}
|
||||||
className="cursor-pointer border-b px-3 py-2 last:border-b-0 hover:bg-gray-100"
|
className="cursor-pointer border-b px-3 py-2 last:border-b-0 hover:bg-muted"
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm font-medium">{dashboard.title}</div>
|
<div className="text-sm font-medium">{dashboard.title}</div>
|
||||||
{dashboard.description && (
|
{dashboard.description && (
|
||||||
<div className="text-xs text-gray-500">{dashboard.description}</div>
|
<div className="text-xs text-muted-foreground">{dashboard.description}</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1012,7 +1012,7 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
|
||||||
dashboard.title.toLowerCase().includes(dashboardSearchText.toLowerCase()) ||
|
dashboard.title.toLowerCase().includes(dashboardSearchText.toLowerCase()) ||
|
||||||
(dashboard.description &&
|
(dashboard.description &&
|
||||||
dashboard.description.toLowerCase().includes(dashboardSearchText.toLowerCase())),
|
dashboard.description.toLowerCase().includes(dashboardSearchText.toLowerCase())),
|
||||||
).length === 0 && <div className="px-3 py-2 text-sm text-gray-500">검색 결과가 없습니다.</div>}
|
).length === 0 && <div className="px-3 py-2 text-sm text-muted-foreground">검색 결과가 없습니다.</div>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -1021,7 +1021,7 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
|
||||||
{/* 선택된 대시보드 정보 표시 */}
|
{/* 선택된 대시보드 정보 표시 */}
|
||||||
{selectedDashboard && (
|
{selectedDashboard && (
|
||||||
<div className="bg-accent rounded-md border p-3">
|
<div className="bg-accent rounded-md border p-3">
|
||||||
<div className="text-sm font-medium text-blue-900">{selectedDashboard.title}</div>
|
<div className="text-sm font-medium text-primary">{selectedDashboard.title}</div>
|
||||||
{selectedDashboard.description && (
|
{selectedDashboard.description && (
|
||||||
<div className="text-primary text-xs">설명: {selectedDashboard.description}</div>
|
<div className="text-primary text-xs">설명: {selectedDashboard.description}</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -745,7 +745,7 @@ export function MenuPermissionsTable({ permissions, onPermissionsChange, roleGro
|
||||||
<span>생성 (C)</span>
|
<span>생성 (C)</span>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
onCheckedChange={(checked) => handleSelectAll("createYn", checked as boolean)}
|
onCheckedChange={(checked) => handleSelectAll("createYn", checked as boolean)}
|
||||||
className="data-[state=checked]:bg-green-600"
|
className="data-[state=checked]:bg-emerald-600"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
|
|
@ -754,7 +754,7 @@ export function MenuPermissionsTable({ permissions, onPermissionsChange, roleGro
|
||||||
<span>조회 (R)</span>
|
<span>조회 (R)</span>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
onCheckedChange={(checked) => handleSelectAll("readYn", checked as boolean)}
|
onCheckedChange={(checked) => handleSelectAll("readYn", checked as boolean)}
|
||||||
className="data-[state=checked]:bg-blue-600"
|
className="data-[state=checked]:bg-primary"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
|
|
@ -772,7 +772,7 @@ export function MenuPermissionsTable({ permissions, onPermissionsChange, roleGro
|
||||||
<span>삭제 (D)</span>
|
<span>삭제 (D)</span>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
onCheckedChange={(checked) => handleSelectAll("deleteYn", checked as boolean)}
|
onCheckedChange={(checked) => handleSelectAll("deleteYn", checked as boolean)}
|
||||||
className="data-[state=checked]:bg-red-600"
|
className="data-[state=checked]:bg-destructive"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
|
|
@ -834,7 +834,7 @@ export function MenuPermissionsTable({ permissions, onPermissionsChange, roleGro
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={menu.createYn === "Y"}
|
checked={menu.createYn === "Y"}
|
||||||
onCheckedChange={(checked) => handlePermissionChange(menu.menuObjid, "createYn", checked as boolean)}
|
onCheckedChange={(checked) => handlePermissionChange(menu.menuObjid, "createYn", checked as boolean)}
|
||||||
className="data-[state=checked]:bg-green-600"
|
className="data-[state=checked]:bg-emerald-600"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between text-sm">
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
|
@ -842,7 +842,7 @@ export function MenuPermissionsTable({ permissions, onPermissionsChange, roleGro
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={menu.readYn === "Y"}
|
checked={menu.readYn === "Y"}
|
||||||
onCheckedChange={(checked) => handlePermissionChange(menu.menuObjid, "readYn", checked as boolean)}
|
onCheckedChange={(checked) => handlePermissionChange(menu.menuObjid, "readYn", checked as boolean)}
|
||||||
className="data-[state=checked]:bg-blue-600"
|
className="data-[state=checked]:bg-primary"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between text-sm">
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
|
@ -858,7 +858,7 @@ export function MenuPermissionsTable({ permissions, onPermissionsChange, roleGro
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={menu.deleteYn === "Y"}
|
checked={menu.deleteYn === "Y"}
|
||||||
onCheckedChange={(checked) => handlePermissionChange(menu.menuObjid, "deleteYn", checked as boolean)}
|
onCheckedChange={(checked) => handlePermissionChange(menu.menuObjid, "deleteYn", checked as boolean)}
|
||||||
className="data-[state=checked]:bg-red-600"
|
className="data-[state=checked]:bg-destructive"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -564,7 +564,7 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }:
|
||||||
{testResult && (
|
{testResult && (
|
||||||
<div
|
<div
|
||||||
className={`rounded-md border p-4 ${
|
className={`rounded-md border p-4 ${
|
||||||
testResult.success ? "border-green-200 bg-green-50" : "border-red-200 bg-red-50"
|
testResult.success ? "border-emerald-200 bg-emerald-50" : "border-destructive/20 bg-destructive/10"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="mb-2 flex items-center justify-between">
|
<div className="mb-2 flex items-center justify-between">
|
||||||
|
|
@ -572,17 +572,17 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }:
|
||||||
{testResult.success ? "성공" : "실패"}
|
{testResult.success ? "성공" : "실패"}
|
||||||
</Badge>
|
</Badge>
|
||||||
{testResult.response_time && (
|
{testResult.response_time && (
|
||||||
<span className="text-sm text-gray-600">응답 시간: {testResult.response_time}ms</span>
|
<span className="text-sm text-muted-foreground">응답 시간: {testResult.response_time}ms</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-sm">{testResult.message}</p>
|
<p className="text-sm">{testResult.message}</p>
|
||||||
|
|
||||||
{testResult.status_code && (
|
{testResult.status_code && (
|
||||||
<p className="mt-1 text-xs text-gray-500">상태 코드: {testResult.status_code}</p>
|
<p className="mt-1 text-xs text-muted-foreground">상태 코드: {testResult.status_code}</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{testResult.error_details && <p className="mt-2 text-xs text-red-600">{testResult.error_details}</p>}
|
{testResult.error_details && <p className="mt-2 text-xs text-destructive">{testResult.error_details}</p>}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -79,9 +79,9 @@ export function RoleDeleteModal({ isOpen, onClose, onSuccess, role }: RoleDelete
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* 경고 메시지 */}
|
{/* 경고 메시지 */}
|
||||||
<div className="rounded-lg border border-orange-300 bg-orange-50 p-4">
|
<div className="rounded-lg border border-orange-300 bg-amber-50 p-4">
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<AlertTriangle className="mt-0.5 h-5 w-5 flex-shrink-0 text-orange-600" />
|
<AlertTriangle className="mt-0.5 h-5 w-5 flex-shrink-0 text-amber-600" />
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<p className="text-sm font-semibold text-orange-900">정말로 삭제하시겠습니까?</p>
|
<p className="text-sm font-semibold text-orange-900">정말로 삭제하시겠습니까?</p>
|
||||||
<p className="text-xs text-orange-800">
|
<p className="text-xs text-orange-800">
|
||||||
|
|
@ -124,7 +124,7 @@ export function RoleDeleteModal({ isOpen, onClose, onSuccess, role }: RoleDelete
|
||||||
<div
|
<div
|
||||||
className={`rounded-lg border p-3 text-sm ${
|
className={`rounded-lg border p-3 text-sm ${
|
||||||
alertType === "success"
|
alertType === "success"
|
||||||
? "border-green-300 bg-green-50 text-green-800"
|
? "border-green-300 bg-emerald-50 text-emerald-800"
|
||||||
: "border-destructive/50 bg-destructive/10 text-destructive"
|
: "border-destructive/50 bg-destructive/10 text-destructive"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -194,7 +194,7 @@ export function RoleFormModal({ isOpen, onClose, onSuccess, editingRole }: RoleF
|
||||||
{/* 권한 그룹명 */}
|
{/* 권한 그룹명 */}
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="authName" className="text-xs sm:text-sm">
|
<Label htmlFor="authName" className="text-xs sm:text-sm">
|
||||||
권한 그룹명 <span className="text-red-500">*</span>
|
권한 그룹명 <span className="text-destructive">*</span>
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="authName"
|
id="authName"
|
||||||
|
|
@ -210,7 +210,7 @@ export function RoleFormModal({ isOpen, onClose, onSuccess, editingRole }: RoleF
|
||||||
{/* 권한 코드 */}
|
{/* 권한 코드 */}
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="authCode" className="text-xs sm:text-sm">
|
<Label htmlFor="authCode" className="text-xs sm:text-sm">
|
||||||
권한 코드 <span className="text-red-500">*</span>
|
권한 코드 <span className="text-destructive">*</span>
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="authCode"
|
id="authCode"
|
||||||
|
|
@ -243,7 +243,7 @@ export function RoleFormModal({ isOpen, onClose, onSuccess, editingRole }: RoleF
|
||||||
) : (
|
) : (
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="companyCode" className="text-xs sm:text-sm">
|
<Label htmlFor="companyCode" className="text-xs sm:text-sm">
|
||||||
회사 <span className="text-red-500">*</span>
|
회사 <span className="text-destructive">*</span>
|
||||||
</Label>
|
</Label>
|
||||||
{isSuperAdmin ? (
|
{isSuperAdmin ? (
|
||||||
<>
|
<>
|
||||||
|
|
@ -345,10 +345,10 @@ export function RoleFormModal({ isOpen, onClose, onSuccess, editingRole }: RoleF
|
||||||
<div
|
<div
|
||||||
className={`rounded-lg border p-3 text-sm ${
|
className={`rounded-lg border p-3 text-sm ${
|
||||||
alertType === "success"
|
alertType === "success"
|
||||||
? "border-green-300 bg-green-50 text-green-800"
|
? "border-green-300 bg-emerald-50 text-emerald-800"
|
||||||
: alertType === "error"
|
: alertType === "error"
|
||||||
? "border-destructive/50 bg-destructive/10 text-destructive"
|
? "border-destructive/50 bg-destructive/10 text-destructive"
|
||||||
: "border-blue-300 bg-blue-50 text-blue-800"
|
: "border-primary/40 bg-primary/10 text-primary"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-start gap-2">
|
<div className="flex items-start gap-2">
|
||||||
|
|
|
||||||
|
|
@ -223,7 +223,7 @@ export const ScreenAssignmentTab: React.FC<ScreenAssignmentTabProps> = ({ menus
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{selectedMenu && (
|
{selectedMenu && (
|
||||||
<div className="rounded-lg border bg-gray-50 p-4">
|
<div className="rounded-lg border bg-muted p-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-medium">
|
<h3 className="font-medium">
|
||||||
|
|
@ -267,7 +267,7 @@ export const ScreenAssignmentTab: React.FC<ScreenAssignmentTabProps> = ({ menus
|
||||||
<Monitor className="h-5 w-5" />
|
<Monitor className="h-5 w-5" />
|
||||||
할당된 화면 ({assignedScreens.length}개)
|
할당된 화면 ({assignedScreens.length}개)
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<Button onClick={openAssignDialog} className="bg-blue-600 hover:bg-blue-700">
|
<Button onClick={openAssignDialog} className="bg-primary hover:bg-primary/90">
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
화면 할당
|
화면 할당
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -275,15 +275,15 @@ export const ScreenAssignmentTab: React.FC<ScreenAssignmentTabProps> = ({ menus
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="py-8 text-center text-gray-500">로딩 중...</div>
|
<div className="py-8 text-center text-muted-foreground">로딩 중...</div>
|
||||||
) : assignedScreens.length === 0 ? (
|
) : assignedScreens.length === 0 ? (
|
||||||
<div className="py-8 text-center text-gray-500">할당된 화면이 없습니다. 화면을 할당해보세요.</div>
|
<div className="py-8 text-center text-muted-foreground">할당된 화면이 없습니다. 화면을 할당해보세요.</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{assignedScreens.map((screen) => (
|
{assignedScreens.map((screen) => (
|
||||||
<div
|
<div
|
||||||
key={screen.screenId}
|
key={screen.screenId}
|
||||||
className="flex items-center justify-between rounded-lg border p-4 hover:bg-gray-50"
|
className="flex items-center justify-between rounded-lg border p-4 hover:bg-muted"
|
||||||
>
|
>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
|
|
@ -298,7 +298,7 @@ export const ScreenAssignmentTab: React.FC<ScreenAssignmentTabProps> = ({ menus
|
||||||
<p className="mt-1 text-sm text-muted-foreground">
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
테이블: {screen.tableName} | 생성일: {screen.createdDate.toLocaleDateString()}
|
테이블: {screen.tableName} | 생성일: {screen.createdDate.toLocaleDateString()}
|
||||||
</p>
|
</p>
|
||||||
{screen.description && <p className="mt-1 text-sm text-gray-500">{screen.description}</p>}
|
{screen.description && <p className="mt-1 text-sm text-muted-foreground">{screen.description}</p>}
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
|
@ -307,7 +307,7 @@ export const ScreenAssignmentTab: React.FC<ScreenAssignmentTabProps> = ({ menus
|
||||||
setSelectedScreen(screen);
|
setSelectedScreen(screen);
|
||||||
setShowUnassignDialog(true);
|
setShowUnassignDialog(true);
|
||||||
}}
|
}}
|
||||||
className="text-destructive hover:text-red-700"
|
className="text-destructive hover:text-destructive"
|
||||||
>
|
>
|
||||||
<X className="h-4 w-4" />
|
<X className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -330,7 +330,7 @@ export const ScreenAssignmentTab: React.FC<ScreenAssignmentTabProps> = ({ menus
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* 검색 */}
|
{/* 검색 */}
|
||||||
<div className="relative">
|
<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
|
<Input
|
||||||
placeholder="화면명 또는 코드로 검색..."
|
placeholder="화면명 또는 코드로 검색..."
|
||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
|
|
@ -342,13 +342,13 @@ export const ScreenAssignmentTab: React.FC<ScreenAssignmentTabProps> = ({ menus
|
||||||
{/* 화면 목록 */}
|
{/* 화면 목록 */}
|
||||||
<div className="max-h-64 space-y-2 overflow-y-auto">
|
<div className="max-h-64 space-y-2 overflow-y-auto">
|
||||||
{filteredAvailableScreens.length === 0 ? (
|
{filteredAvailableScreens.length === 0 ? (
|
||||||
<div className="py-4 text-center text-gray-500">할당 가능한 화면이 없습니다.</div>
|
<div className="py-4 text-center text-muted-foreground">할당 가능한 화면이 없습니다.</div>
|
||||||
) : (
|
) : (
|
||||||
filteredAvailableScreens.map((screen) => (
|
filteredAvailableScreens.map((screen) => (
|
||||||
<div
|
<div
|
||||||
key={screen.screenId}
|
key={screen.screenId}
|
||||||
className={`cursor-pointer rounded-lg border p-3 transition-colors ${
|
className={`cursor-pointer rounded-lg border p-3 transition-colors ${
|
||||||
selectedScreen?.screenId === screen.screenId ? "border-primary bg-accent" : "hover:bg-gray-50"
|
selectedScreen?.screenId === screen.screenId ? "border-primary bg-accent" : "hover:bg-muted"
|
||||||
}`}
|
}`}
|
||||||
onClick={() => setSelectedScreen(screen)}
|
onClick={() => setSelectedScreen(screen)}
|
||||||
>
|
>
|
||||||
|
|
@ -385,7 +385,7 @@ export const ScreenAssignmentTab: React.FC<ScreenAssignmentTabProps> = ({ menus
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
<AlertDialogCancel onClick={() => setSelectedScreen(null)}>취소</AlertDialogCancel>
|
<AlertDialogCancel onClick={() => setSelectedScreen(null)}>취소</AlertDialogCancel>
|
||||||
<AlertDialogAction onClick={handleUnassignScreen} className="bg-red-600 hover:bg-red-700">
|
<AlertDialogAction onClick={handleUnassignScreen} className="bg-destructive hover:bg-red-700">
|
||||||
할당 해제
|
할당 해제
|
||||||
</AlertDialogAction>
|
</AlertDialogAction>
|
||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
|
|
|
||||||
|
|
@ -116,7 +116,7 @@ export function SortableCodeItem({
|
||||||
}}
|
}}
|
||||||
onPointerDown={(e) => e.stopPropagation()}
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
onMouseDown={(e) => e.stopPropagation()}
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
className="text-muted-foreground hover:text-foreground -ml-1 flex h-5 w-5 items-center justify-center rounded transition-colors hover:bg-gray-100"
|
className="text-muted-foreground hover:text-foreground -ml-1 flex h-5 w-5 items-center justify-center rounded transition-colors hover:bg-muted"
|
||||||
title={isExpanded ? "접기" : "펼치기"}
|
title={isExpanded ? "접기" : "펼치기"}
|
||||||
>
|
>
|
||||||
{isExpanded ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
|
{isExpanded ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
|
||||||
|
|
@ -135,12 +135,12 @@ export function SortableCodeItem({
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
{depth === 2 && (
|
{depth === 2 && (
|
||||||
<Badge variant="outline" className="bg-blue-50 px-1.5 py-0 text-[10px] text-blue-600">
|
<Badge variant="outline" className="bg-primary/10 px-1.5 py-0 text-[10px] text-primary">
|
||||||
중분류
|
중분류
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
{depth === 3 && (
|
{depth === 3 && (
|
||||||
<Badge variant="outline" className="bg-green-50 px-1.5 py-0 text-[10px] text-green-600">
|
<Badge variant="outline" className="bg-emerald-50 px-1.5 py-0 text-[10px] text-emerald-600">
|
||||||
소분류
|
소분류
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
|
|
@ -196,7 +196,7 @@ export function SortableCodeItem({
|
||||||
onAddChild();
|
onAddChild();
|
||||||
}}
|
}}
|
||||||
title="하위 코드 추가"
|
title="하위 코드 추가"
|
||||||
className="text-blue-600 hover:bg-blue-50 hover:text-blue-700"
|
className="text-primary hover:bg-primary/10 hover:text-primary"
|
||||||
>
|
>
|
||||||
<Plus className="h-3 w-3" />
|
<Plus className="h-3 w-3" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
|
||||||
|
|
@ -102,11 +102,11 @@ export function TableLogViewer({ tableName, open, onOpenChange }: TableLogViewer
|
||||||
const getOperationBadge = (type: string) => {
|
const getOperationBadge = (type: string) => {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case "INSERT":
|
case "INSERT":
|
||||||
return <Badge className="bg-green-500">추가</Badge>;
|
return <Badge className="bg-emerald-500">추가</Badge>;
|
||||||
case "UPDATE":
|
case "UPDATE":
|
||||||
return <Badge className="bg-blue-500">수정</Badge>;
|
return <Badge className="bg-primary">수정</Badge>;
|
||||||
case "DELETE":
|
case "DELETE":
|
||||||
return <Badge className="bg-red-500">삭제</Badge>;
|
return <Badge className="bg-destructive">삭제</Badge>;
|
||||||
default:
|
default:
|
||||||
return <Badge>{type}</Badge>;
|
return <Badge>{type}</Badge>;
|
||||||
}
|
}
|
||||||
|
|
@ -150,7 +150,7 @@ export function TableLogViewer({ tableName, open, onOpenChange }: TableLogViewer
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-3">
|
<div className="grid grid-cols-2 gap-3 md:grid-cols-3">
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1 block text-sm text-gray-600">작업 유형</label>
|
<label className="mb-1 block text-sm text-muted-foreground">작업 유형</label>
|
||||||
{/* Radix UI Select v2.x: 빈 문자열 value="" 금지 → "__all__" 사용 */}
|
{/* Radix UI Select v2.x: 빈 문자열 value="" 금지 → "__all__" 사용 */}
|
||||||
<Select
|
<Select
|
||||||
value={operationType || "__all__"}
|
value={operationType || "__all__"}
|
||||||
|
|
@ -169,22 +169,22 @@ export function TableLogViewer({ tableName, open, onOpenChange }: TableLogViewer
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1 block text-sm text-gray-600">시작 날짜</label>
|
<label className="mb-1 block text-sm text-muted-foreground">시작 날짜</label>
|
||||||
<Input type="date" value={startDate} onChange={(e) => setStartDate(e.target.value)} />
|
<Input type="date" value={startDate} onChange={(e) => setStartDate(e.target.value)} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1 block text-sm text-gray-600">종료 날짜</label>
|
<label className="mb-1 block text-sm text-muted-foreground">종료 날짜</label>
|
||||||
<Input type="date" value={endDate} onChange={(e) => setEndDate(e.target.value)} />
|
<Input type="date" value={endDate} onChange={(e) => setEndDate(e.target.value)} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1 block text-sm text-gray-600">변경자</label>
|
<label className="mb-1 block text-sm text-muted-foreground">변경자</label>
|
||||||
<Input placeholder="사용자 ID" value={changedBy} onChange={(e) => setChangedBy(e.target.value)} />
|
<Input placeholder="사용자 ID" value={changedBy} onChange={(e) => setChangedBy(e.target.value)} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1 block text-sm text-gray-600">원본 ID</label>
|
<label className="mb-1 block text-sm text-muted-foreground">원본 ID</label>
|
||||||
<Input placeholder="레코드 ID" value={originalId} onChange={(e) => setOriginalId(e.target.value)} />
|
<Input placeholder="레코드 ID" value={originalId} onChange={(e) => setOriginalId(e.target.value)} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -204,7 +204,7 @@ export function TableLogViewer({ tableName, open, onOpenChange }: TableLogViewer
|
||||||
<LoadingSpinner />
|
<LoadingSpinner />
|
||||||
</div>
|
</div>
|
||||||
) : logs.length === 0 ? (
|
) : logs.length === 0 ? (
|
||||||
<div className="flex h-64 items-center justify-center text-gray-500">변경 이력이 없습니다.</div>
|
<div className="flex h-64 items-center justify-center text-muted-foreground">변경 이력이 없습니다.</div>
|
||||||
) : (
|
) : (
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
|
|
@ -243,7 +243,7 @@ export function TableLogViewer({ tableName, open, onOpenChange }: TableLogViewer
|
||||||
|
|
||||||
{/* 페이지네이션 */}
|
{/* 페이지네이션 */}
|
||||||
<div className="flex items-center justify-between border-t pt-4">
|
<div className="flex items-center justify-between border-t pt-4">
|
||||||
<div className="text-sm text-gray-600">
|
<div className="text-sm text-muted-foreground">
|
||||||
전체 {total}건 (페이지 {page} / {totalPages})
|
전체 {total}건 (페이지 {page} / {totalPages})
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
|
|
|
||||||
|
|
@ -157,12 +157,12 @@ export function TemplateImportExport({ onTemplateImported }: TemplateImportExpor
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<div
|
<div
|
||||||
className="cursor-pointer rounded-lg border-2 border-dashed border-gray-300 p-8 text-center transition-colors hover:border-gray-400"
|
className="cursor-pointer rounded-lg border-2 border-dashed border-input p-8 text-center transition-colors hover:border-input"
|
||||||
onClick={triggerFileSelect}
|
onClick={triggerFileSelect}
|
||||||
>
|
>
|
||||||
<Upload className="mx-auto mb-4 h-12 w-12 text-gray-400" />
|
<Upload className="mx-auto mb-4 h-12 w-12 text-muted-foreground/70" />
|
||||||
<p className="mb-2 text-lg font-medium text-gray-900">템플릿 JSON 파일을 선택하세요</p>
|
<p className="mb-2 text-lg font-medium text-foreground">템플릿 JSON 파일을 선택하세요</p>
|
||||||
<p className="text-sm text-gray-500">또는 아래에 JSON 내용을 직접 입력하세요</p>
|
<p className="text-sm text-muted-foreground">또는 아래에 JSON 내용을 직접 입력하세요</p>
|
||||||
</div>
|
</div>
|
||||||
<input ref={fileInputRef} type="file" accept=".json" onChange={handleFileUpload} className="hidden" />
|
<input ref={fileInputRef} type="file" accept=".json" onChange={handleFileUpload} className="hidden" />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|
@ -196,7 +196,7 @@ export function TemplateImportExport({ onTemplateImported }: TemplateImportExpor
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center text-lg">
|
<CardTitle className="flex items-center text-lg">
|
||||||
<CheckCircle className="mr-2 h-5 w-5 text-green-600" />
|
<CheckCircle className="mr-2 h-5 w-5 text-emerald-600" />
|
||||||
3. 템플릿 미리보기
|
3. 템플릿 미리보기
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
|
||||||
|
|
@ -54,28 +54,28 @@ export function UserAuthEditModal({ isOpen, onClose, onSuccess, user }: UserAuth
|
||||||
label: "회사 관리자",
|
label: "회사 관리자",
|
||||||
description: "자기 회사 데이터 및 사용자 관리 가능",
|
description: "자기 회사 데이터 및 사용자 관리 가능",
|
||||||
icon: <Building2 className="h-4 w-4" />,
|
icon: <Building2 className="h-4 w-4" />,
|
||||||
color: "text-blue-600",
|
color: "text-primary",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: "USER",
|
value: "USER",
|
||||||
label: "일반 사용자",
|
label: "일반 사용자",
|
||||||
description: "자기 회사 데이터 조회/수정만 가능",
|
description: "자기 회사 데이터 조회/수정만 가능",
|
||||||
icon: <User className="h-4 w-4" />,
|
icon: <User className="h-4 w-4" />,
|
||||||
color: "text-gray-600",
|
color: "text-muted-foreground",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: "GUEST",
|
value: "GUEST",
|
||||||
label: "게스트",
|
label: "게스트",
|
||||||
description: "제한된 조회 권한",
|
description: "제한된 조회 권한",
|
||||||
icon: <Users className="h-4 w-4" />,
|
icon: <Users className="h-4 w-4" />,
|
||||||
color: "text-green-600",
|
color: "text-emerald-600",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: "PARTNER",
|
value: "PARTNER",
|
||||||
label: "협력업체",
|
label: "협력업체",
|
||||||
description: "협력업체 전용 권한",
|
description: "협력업체 전용 권한",
|
||||||
icon: <Shield className="h-4 w-4" />,
|
icon: <Shield className="h-4 w-4" />,
|
||||||
color: "text-orange-600",
|
color: "text-amber-600",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
@ -158,7 +158,7 @@ export function UserAuthEditModal({ isOpen, onClose, onSuccess, user }: UserAuth
|
||||||
{/* 권한 선택 */}
|
{/* 권한 선택 */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="userType" className="text-sm font-medium">
|
<Label htmlFor="userType" className="text-sm font-medium">
|
||||||
새로운 권한 <span className="text-red-500">*</span>
|
새로운 권한 <span className="text-destructive">*</span>
|
||||||
</Label>
|
</Label>
|
||||||
<Select value={selectedUserType} onValueChange={setSelectedUserType}>
|
<Select value={selectedUserType} onValueChange={setSelectedUserType}>
|
||||||
<SelectTrigger className="h-10">
|
<SelectTrigger className="h-10">
|
||||||
|
|
@ -180,9 +180,9 @@ export function UserAuthEditModal({ isOpen, onClose, onSuccess, user }: UserAuth
|
||||||
|
|
||||||
{/* SUPER_ADMIN 경고 */}
|
{/* SUPER_ADMIN 경고 */}
|
||||||
{showConfirmation && selectedUserType === "SUPER_ADMIN" && (
|
{showConfirmation && selectedUserType === "SUPER_ADMIN" && (
|
||||||
<div className="rounded-lg border border-orange-300 bg-orange-50 p-4">
|
<div className="rounded-lg border border-orange-300 bg-amber-50 p-4">
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<AlertTriangle className="mt-0.5 h-5 w-5 flex-shrink-0 text-orange-600" />
|
<AlertTriangle className="mt-0.5 h-5 w-5 flex-shrink-0 text-amber-600" />
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<p className="text-sm font-semibold text-orange-900">최고 관리자 권한 부여 경고</p>
|
<p className="text-sm font-semibold text-orange-900">최고 관리자 권한 부여 경고</p>
|
||||||
<p className="text-xs text-orange-800">
|
<p className="text-xs text-orange-800">
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue