feat: Enhance approval request handling and user management
- Updated the approval request controller to include target_record_id in query parameters for improved filtering. - Refactored the approval request creation logic to merge approval_mode into target_record_data, allowing for better handling of approval processes. - Enhanced the user search functionality in the approval request modal to accommodate additional user attributes such as position and department. - Improved error handling messages for clarity regarding required fields in the approval request modal. - Added new menu item for accessing the approval box directly from user dropdown and app layout. Made-with: Cursor
This commit is contained in:
parent
c22b468599
commit
f6a2668bdc
|
|
@ -481,7 +481,7 @@ export class ApprovalRequestController {
|
||||||
return res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
|
return res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
|
||||||
}
|
}
|
||||||
|
|
||||||
const { status, target_table, requester_id, my_approvals, page = "1", limit = "20" } = req.query;
|
const { status, target_table, target_record_id, requester_id, my_approvals, page = "1", limit = "20" } = req.query;
|
||||||
|
|
||||||
const conditions: string[] = ["r.company_code = $1"];
|
const conditions: string[] = ["r.company_code = $1"];
|
||||||
const params: any[] = [companyCode];
|
const params: any[] = [companyCode];
|
||||||
|
|
@ -495,6 +495,10 @@ export class ApprovalRequestController {
|
||||||
conditions.push(`r.target_table = $${idx++}`);
|
conditions.push(`r.target_table = $${idx++}`);
|
||||||
params.push(target_table);
|
params.push(target_table);
|
||||||
}
|
}
|
||||||
|
if (target_record_id) {
|
||||||
|
conditions.push(`r.target_record_id = $${idx++}`);
|
||||||
|
params.push(target_record_id);
|
||||||
|
}
|
||||||
if (requester_id) {
|
if (requester_id) {
|
||||||
conditions.push(`r.requester_id = $${idx++}`);
|
conditions.push(`r.requester_id = $${idx++}`);
|
||||||
params.push(requester_id);
|
params.push(requester_id);
|
||||||
|
|
@ -595,10 +599,11 @@ export class ApprovalRequestController {
|
||||||
title, description, definition_id, target_table, target_record_id,
|
title, description, definition_id, target_table, target_record_id,
|
||||||
target_record_data, screen_id, button_component_id,
|
target_record_data, screen_id, button_component_id,
|
||||||
approvers, // [{ approver_id, approver_name, approver_position, approver_dept, approver_label }]
|
approvers, // [{ approver_id, approver_name, approver_position, approver_dept, approver_label }]
|
||||||
|
approval_mode, // "sequential" | "parallel"
|
||||||
} = req.body;
|
} = req.body;
|
||||||
|
|
||||||
if (!title || !target_table || !target_record_id) {
|
if (!title || !target_table) {
|
||||||
return res.status(400).json({ success: false, message: "제목, 대상 테이블, 대상 레코드 ID는 필수입니다." });
|
return res.status(400).json({ success: false, message: "제목과 대상 테이블은 필수입니다." });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!Array.isArray(approvers) || approvers.length === 0) {
|
if (!Array.isArray(approvers) || approvers.length === 0) {
|
||||||
|
|
@ -609,6 +614,15 @@ export class ApprovalRequestController {
|
||||||
const userName = req.user?.userName || "";
|
const userName = req.user?.userName || "";
|
||||||
const deptName = req.user?.deptName || "";
|
const deptName = req.user?.deptName || "";
|
||||||
|
|
||||||
|
const isParallel = approval_mode === "parallel";
|
||||||
|
const totalSteps = approvers.length;
|
||||||
|
|
||||||
|
// approval_mode를 target_record_data에 병합 저장
|
||||||
|
const mergedRecordData = {
|
||||||
|
...(target_record_data || {}),
|
||||||
|
approval_mode: approval_mode || "sequential",
|
||||||
|
};
|
||||||
|
|
||||||
let result: any;
|
let result: any;
|
||||||
await transaction(async (client) => {
|
await transaction(async (client) => {
|
||||||
// 결재 요청 생성
|
// 결재 요청 생성
|
||||||
|
|
@ -621,18 +635,21 @@ export class ApprovalRequestController {
|
||||||
) VALUES ($1, $2, $3, $4, $5, $6, 'requested', 1, $7, $8, $9, $10, $11, $12, $13)
|
) VALUES ($1, $2, $3, $4, $5, $6, 'requested', 1, $7, $8, $9, $10, $11, $12, $13)
|
||||||
RETURNING *`,
|
RETURNING *`,
|
||||||
[
|
[
|
||||||
title, description, definition_id, target_table, target_record_id,
|
title, description, definition_id, target_table, target_record_id || null,
|
||||||
target_record_data ? JSON.stringify(target_record_data) : null,
|
JSON.stringify(mergedRecordData),
|
||||||
approvers.length,
|
totalSteps,
|
||||||
userId, userName, deptName,
|
userId, userName, deptName,
|
||||||
screen_id, button_component_id, companyCode,
|
screen_id, button_component_id, companyCode,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
result = reqRows[0];
|
result = reqRows[0];
|
||||||
|
|
||||||
// 결재 라인 생성 (첫 번째 단계는 pending, 나머지는 waiting)
|
// 결재 라인 생성
|
||||||
|
// 동시결재: 모든 결재자 pending (step_order는 고유값) / 다단결재: 첫 번째만 pending
|
||||||
for (let i = 0; i < approvers.length; i++) {
|
for (let i = 0; i < approvers.length; i++) {
|
||||||
const approver = approvers[i];
|
const approver = approvers[i];
|
||||||
|
const lineStatus = isParallel ? "pending" : (i === 0 ? "pending" : "waiting");
|
||||||
|
|
||||||
await client.query(
|
await client.query(
|
||||||
`INSERT INTO approval_lines (
|
`INSERT INTO approval_lines (
|
||||||
request_id, step_order, approver_id, approver_name, approver_position,
|
request_id, step_order, approver_id, approver_name, approver_position,
|
||||||
|
|
@ -645,8 +662,8 @@ export class ApprovalRequestController {
|
||||||
approver.approver_name || null,
|
approver.approver_name || null,
|
||||||
approver.approver_position || null,
|
approver.approver_position || null,
|
||||||
approver.approver_dept || null,
|
approver.approver_dept || null,
|
||||||
approver.approver_label || `${i + 1}차 결재`,
|
approver.approver_label || (isParallel ? "동시 결재" : `${i + 1}차 결재`),
|
||||||
i === 0 ? "pending" : "waiting",
|
lineStatus,
|
||||||
companyCode,
|
companyCode,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
@ -777,12 +794,41 @@ export class ApprovalLineController {
|
||||||
WHERE request_id = $3`,
|
WHERE request_id = $3`,
|
||||||
[userId, comment || null, line.request_id]
|
[userId, comment || null, line.request_id]
|
||||||
);
|
);
|
||||||
|
// 남은 pending/waiting 라인도 skipped 처리
|
||||||
|
await client.query(
|
||||||
|
`UPDATE approval_lines SET status = 'skipped'
|
||||||
|
WHERE request_id = $1 AND status IN ('pending', 'waiting') AND line_id != $2`,
|
||||||
|
[line.request_id, lineId]
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
// 승인: 다음 단계 활성화 또는 최종 완료
|
// 승인: 동시결재 vs 다단결재 분기
|
||||||
|
const recordData = request.target_record_data;
|
||||||
|
const isParallelMode = recordData?.approval_mode === "parallel";
|
||||||
|
|
||||||
|
if (isParallelMode) {
|
||||||
|
// 동시결재: 남은 pending 라인이 있는지 확인
|
||||||
|
const { rows: remainingLines } = await client.query(
|
||||||
|
`SELECT COUNT(*) as cnt FROM approval_lines
|
||||||
|
WHERE request_id = $1 AND status = 'pending' AND line_id != $2 AND company_code = $3`,
|
||||||
|
[line.request_id, lineId, companyCode]
|
||||||
|
);
|
||||||
|
const remaining = parseInt(remainingLines[0]?.cnt || "0");
|
||||||
|
|
||||||
|
if (remaining === 0) {
|
||||||
|
// 모든 동시 결재자 승인 완료 → 최종 승인
|
||||||
|
await client.query(
|
||||||
|
`UPDATE approval_requests SET status = 'approved', final_approver_id = $1, final_comment = $2,
|
||||||
|
completed_at = NOW(), updated_at = NOW()
|
||||||
|
WHERE request_id = $3`,
|
||||||
|
[userId, comment || null, line.request_id]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// 아직 남은 결재자 있으면 대기 (상태 변경 없음)
|
||||||
|
} else {
|
||||||
|
// 다단결재: 다음 단계 활성화 또는 최종 완료
|
||||||
const nextStep = line.step_order + 1;
|
const nextStep = line.step_order + 1;
|
||||||
|
|
||||||
if (nextStep <= request.total_steps) {
|
if (nextStep <= request.total_steps) {
|
||||||
// 다음 결재자를 pending으로 변경
|
|
||||||
await client.query(
|
await client.query(
|
||||||
`UPDATE approval_lines SET status = 'pending'
|
`UPDATE approval_lines SET status = 'pending'
|
||||||
WHERE request_id = $1 AND step_order = $2 AND company_code = $3`,
|
WHERE request_id = $1 AND step_order = $2 AND company_code = $3`,
|
||||||
|
|
@ -793,7 +839,6 @@ export class ApprovalLineController {
|
||||||
[nextStep, line.request_id]
|
[nextStep, line.request_id]
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
// 마지막 단계 승인 → 최종 완료
|
|
||||||
await client.query(
|
await client.query(
|
||||||
`UPDATE approval_requests SET status = 'approved', final_approver_id = $1, final_comment = $2,
|
`UPDATE approval_requests SET status = 'approved', final_approver_id = $1, final_comment = $2,
|
||||||
completed_at = NOW(), updated_at = NOW()
|
completed_at = NOW(), updated_at = NOW()
|
||||||
|
|
@ -802,6 +847,7 @@ export class ApprovalLineController {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return res.json({ success: true, message: action === "approved" ? "승인 처리되었습니다." : "반려 처리되었습니다." });
|
return res.json({ success: true, message: action === "approved" ? "승인 처리되었습니다." : "반려 처리되었습니다." });
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,342 @@
|
||||||
|
# 결재 시스템 구현 현황
|
||||||
|
|
||||||
|
## 1. 개요
|
||||||
|
|
||||||
|
어떤 화면/테이블에서든 결재 버튼을 추가하여 다단계(순차) 및 다중(병렬) 결재를 처리할 수 있는 범용 결재 시스템.
|
||||||
|
|
||||||
|
### 핵심 특징
|
||||||
|
- **범용성**: 특정 테이블에 종속되지 않고 어떤 화면에서든 사용 가능
|
||||||
|
- **멀티테넌시**: 모든 데이터가 `company_code`로 격리
|
||||||
|
- **사용자 주도**: 결재 요청 시 결재 모드/결재자를 직접 설정 (관리자 사전 세팅 불필요)
|
||||||
|
- **컴포넌트 연동**: 버튼 액션 타입 + 결재 단계 시각화 컴포넌트 제공
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 아키텍처
|
||||||
|
|
||||||
|
```
|
||||||
|
[버튼 클릭 (approval 액션)]
|
||||||
|
↓
|
||||||
|
[ButtonActionExecutor] → CustomEvent('open-approval-modal') 발송
|
||||||
|
↓
|
||||||
|
[ApprovalGlobalListener] → 이벤트 수신
|
||||||
|
↓
|
||||||
|
[ApprovalRequestModal] → 결재 모드/결재자 선택 UI
|
||||||
|
↓
|
||||||
|
[POST /api/approval/requests] → 결재 요청 생성
|
||||||
|
↓
|
||||||
|
[approval_requests + approval_lines 테이블에 저장]
|
||||||
|
↓
|
||||||
|
[결재함 / 결재 단계 컴포넌트에서 조회 및 처리]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 데이터베이스
|
||||||
|
|
||||||
|
### 마이그레이션 파일
|
||||||
|
- `db/migrations/100_create_approval_system.sql`
|
||||||
|
|
||||||
|
### 테이블 구조
|
||||||
|
|
||||||
|
| 테이블 | 용도 | 주요 컬럼 |
|
||||||
|
|--------|------|-----------|
|
||||||
|
| `approval_definitions` | 결재 유형 정의 (구매결재, 문서결재 등) | definition_id, definition_name, max_steps, company_code |
|
||||||
|
| `approval_line_templates` | 결재선 템플릿 (미리 저장된 결재선) | template_id, template_name, definition_id, company_code |
|
||||||
|
| `approval_line_template_steps` | 템플릿별 결재 단계 | step_id, template_id, step_order, approver_user_id, company_code |
|
||||||
|
| `approval_requests` | 실제 결재 요청 건 | request_id, title, target_table, target_record_id, status, requester_id, company_code |
|
||||||
|
| `approval_lines` | 결재 건별 각 단계 결재자 | line_id, request_id, step_order, approver_id, status, comment, company_code |
|
||||||
|
|
||||||
|
### 결재 상태 흐름
|
||||||
|
|
||||||
|
```
|
||||||
|
[requested] → [in_progress] → [approved] (모든 단계 승인)
|
||||||
|
→ [rejected] (어느 단계에서든 반려)
|
||||||
|
→ [cancelled] (요청자가 취소)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### approval_requests.status
|
||||||
|
| 상태 | 의미 |
|
||||||
|
|------|------|
|
||||||
|
| `requested` | 결재 요청됨 (1단계 결재자 처리 대기) |
|
||||||
|
| `in_progress` | 결재 진행 중 (2단계 이상 진행) |
|
||||||
|
| `approved` | 최종 승인 완료 |
|
||||||
|
| `rejected` | 반려됨 |
|
||||||
|
| `cancelled` | 요청자에 의해 취소 |
|
||||||
|
|
||||||
|
#### approval_lines.status
|
||||||
|
| 상태 | 의미 |
|
||||||
|
|------|------|
|
||||||
|
| `waiting` | 아직 차례가 아님 |
|
||||||
|
| `pending` | 현재 결재 차례 (처리 대기) |
|
||||||
|
| `approved` | 승인 완료 |
|
||||||
|
| `rejected` | 반려 |
|
||||||
|
| `skipped` | 이전 단계 반려로 스킵됨 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 백엔드 API
|
||||||
|
|
||||||
|
### 파일 위치
|
||||||
|
- **컨트롤러**: `backend-node/src/controllers/approvalController.ts`
|
||||||
|
- **라우트**: `backend-node/src/routes/approvalRoutes.ts`
|
||||||
|
|
||||||
|
### API 엔드포인트
|
||||||
|
|
||||||
|
#### 결재 유형 (Definitions)
|
||||||
|
| 메서드 | 경로 | 설명 |
|
||||||
|
|--------|------|------|
|
||||||
|
| GET | `/api/approval/definitions` | 결재 유형 목록 |
|
||||||
|
| GET | `/api/approval/definitions/:id` | 결재 유형 상세 |
|
||||||
|
| POST | `/api/approval/definitions` | 결재 유형 생성 |
|
||||||
|
| PUT | `/api/approval/definitions/:id` | 결재 유형 수정 |
|
||||||
|
| DELETE | `/api/approval/definitions/:id` | 결재 유형 삭제 |
|
||||||
|
|
||||||
|
#### 결재선 템플릿 (Templates)
|
||||||
|
| 메서드 | 경로 | 설명 |
|
||||||
|
|--------|------|------|
|
||||||
|
| GET | `/api/approval/templates` | 템플릿 목록 |
|
||||||
|
| GET | `/api/approval/templates/:id` | 템플릿 상세 (단계 포함) |
|
||||||
|
| POST | `/api/approval/templates` | 템플릿 생성 |
|
||||||
|
| PUT | `/api/approval/templates/:id` | 템플릿 수정 |
|
||||||
|
| DELETE | `/api/approval/templates/:id` | 템플릿 삭제 |
|
||||||
|
|
||||||
|
#### 결재 요청 (Requests)
|
||||||
|
| 메서드 | 경로 | 설명 |
|
||||||
|
|--------|------|------|
|
||||||
|
| GET | `/api/approval/requests` | 결재 요청 목록 (필터 가능) |
|
||||||
|
| GET | `/api/approval/requests/:id` | 결재 요청 상세 (결재 라인 포함) |
|
||||||
|
| POST | `/api/approval/requests` | 결재 요청 생성 |
|
||||||
|
| POST | `/api/approval/requests/:id/cancel` | 결재 취소 |
|
||||||
|
|
||||||
|
#### 결재 라인 처리 (Lines)
|
||||||
|
| 메서드 | 경로 | 설명 |
|
||||||
|
|--------|------|------|
|
||||||
|
| GET | `/api/approval/my-pending` | 내 미처리 결재 목록 |
|
||||||
|
| POST | `/api/approval/lines/:lineId/process` | 승인/반려 처리 |
|
||||||
|
|
||||||
|
### 결재 요청 생성 시 입력
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface CreateApprovalRequestInput {
|
||||||
|
title: string; // 결재 제목
|
||||||
|
description?: string; // 결재 설명
|
||||||
|
target_table: string; // 대상 테이블명 (예: sales_order_mng)
|
||||||
|
target_record_id?: string; // 대상 레코드 ID (선택)
|
||||||
|
approval_mode?: "sequential" | "parallel"; // 결재 모드
|
||||||
|
approvers: { // 결재자 목록
|
||||||
|
approver_id: string;
|
||||||
|
approver_name?: string;
|
||||||
|
approver_position?: string;
|
||||||
|
approver_dept?: string;
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 결재 처리 로직
|
||||||
|
|
||||||
|
#### 순차 결재 (sequential)
|
||||||
|
1. 첫 번째 결재자 `status = 'pending'`, 나머지 `'waiting'`
|
||||||
|
2. 1단계 승인 → 2단계 `'pending'`으로 변경
|
||||||
|
3. 모든 단계 승인 → `approval_requests.status = 'approved'`
|
||||||
|
4. 어느 단계에서 반려 → 이후 단계 `'skipped'`, 요청 `'rejected'`
|
||||||
|
|
||||||
|
#### 병렬 결재 (parallel)
|
||||||
|
1. 모든 결재자 `status = 'pending'` (동시 처리)
|
||||||
|
2. 모든 결재자 승인 → `'approved'`
|
||||||
|
3. 한 명이라도 반려 → `'rejected'`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 프론트엔드
|
||||||
|
|
||||||
|
### 5.1 결재 요청 모달
|
||||||
|
|
||||||
|
**파일**: `frontend/components/approval/ApprovalRequestModal.tsx`
|
||||||
|
|
||||||
|
- 결재 모드 선택 (다단 결재 / 다중 결재)
|
||||||
|
- 결재자 검색 (사용자 API 검색, 한글/영문/ID 검색 가능)
|
||||||
|
- 결재자 추가/삭제, 순서 변경 (순차 결재 시)
|
||||||
|
- 대상 테이블/레코드 ID 자동 세팅
|
||||||
|
|
||||||
|
### 5.2 결재 글로벌 리스너
|
||||||
|
|
||||||
|
**파일**: `frontend/components/approval/ApprovalGlobalListener.tsx`
|
||||||
|
|
||||||
|
- `open-approval-modal` CustomEvent를 전역으로 수신
|
||||||
|
- 이벤트의 `detail`에서 `targetTable`, `targetRecordId`, `formData` 추출
|
||||||
|
- `ApprovalRequestModal` 열기
|
||||||
|
|
||||||
|
### 5.3 결재함 페이지
|
||||||
|
|
||||||
|
**파일**: `frontend/app/(main)/admin/approvalBox/page.tsx`
|
||||||
|
|
||||||
|
- 탭 구성: 보낸 결재 / 받은 결재 / 완료된 결재
|
||||||
|
- 결재 상태별 필터링
|
||||||
|
- 결재 상세 조회 및 승인/반려 처리
|
||||||
|
|
||||||
|
**진입점**: 사용자 프로필 드롭다운 > "결재함"
|
||||||
|
|
||||||
|
### 5.4 결재 단계 시각화 컴포넌트 (v2-approval-step)
|
||||||
|
|
||||||
|
**파일 위치**: `frontend/lib/registry/components/v2-approval-step/`
|
||||||
|
|
||||||
|
| 파일 | 역할 |
|
||||||
|
|------|------|
|
||||||
|
| `types.ts` | ApprovalStepConfig 타입 정의 |
|
||||||
|
| `ApprovalStepComponent.tsx` | 결재 단계 시각화 UI (가로형 스테퍼 / 세로형 타임라인) |
|
||||||
|
| `ApprovalStepConfigPanel.tsx` | 설정 패널 (대상 테이블/컬럼 Combobox, 표시 옵션) |
|
||||||
|
| `ApprovalStepRenderer.tsx` | 컴포넌트 레지스트리 등록 |
|
||||||
|
| `index.ts` | 컴포넌트 정의 (이름, 태그, 기본값 등) |
|
||||||
|
|
||||||
|
#### 설정 항목
|
||||||
|
| 설정 | 설명 |
|
||||||
|
|------|------|
|
||||||
|
| 대상 테이블 | 결재를 걸 데이터가 있는 테이블 (Combobox 검색) |
|
||||||
|
| 레코드 ID 필드명 | 테이블의 PK 컬럼 (Combobox 검색) |
|
||||||
|
| 표시 모드 | 가로형 스테퍼 / 세로형 타임라인 |
|
||||||
|
| 부서/직급 표시 | 결재자의 부서/직급 정보 표시 여부 |
|
||||||
|
| 결재 코멘트 표시 | 승인/반려 시 입력한 코멘트 표시 여부 |
|
||||||
|
| 처리 시각 표시 | 결재 처리 시각 표시 여부 |
|
||||||
|
| 콤팩트 모드 | 작게 표시 |
|
||||||
|
|
||||||
|
### 5.5 API 클라이언트
|
||||||
|
|
||||||
|
**파일**: `frontend/lib/api/approval.ts`
|
||||||
|
|
||||||
|
| 함수 | 용도 |
|
||||||
|
|------|------|
|
||||||
|
| `getApprovalDefinitions()` | 결재 유형 목록 조회 |
|
||||||
|
| `getApprovalTemplates()` | 결재선 템플릿 목록 조회 |
|
||||||
|
| `getApprovalRequests()` | 결재 요청 목록 조회 (필터 지원) |
|
||||||
|
| `getApprovalRequest(id)` | 결재 요청 상세 조회 |
|
||||||
|
| `createApprovalRequest(data)` | 결재 요청 생성 |
|
||||||
|
| `cancelApprovalRequest(id)` | 결재 취소 |
|
||||||
|
| `getMyPendingApprovals()` | 내 미처리 결재 목록 |
|
||||||
|
| `processApprovalLine(lineId, data)` | 승인/반려 처리 |
|
||||||
|
|
||||||
|
### 5.6 버튼 액션 연동
|
||||||
|
|
||||||
|
#### 관련 파일
|
||||||
|
| 파일 | 수정 내용 |
|
||||||
|
|------|-----------|
|
||||||
|
| `frontend/lib/utils/buttonActions.ts` | `ButtonActionType`에 `"approval"` 추가, `handleApproval` 구현 |
|
||||||
|
| `frontend/lib/utils/improvedButtonActionExecutor.ts` | `approval` 액션 핸들러 추가 |
|
||||||
|
| `frontend/lib/registry/components/v2-button-primary/ButtonPrimaryComponent.tsx` | `silentActions`에 `"approval"` 추가 |
|
||||||
|
| `frontend/components/screen/config-panels/ButtonConfigPanel.tsx` | 결재 액션 설정 UI (대상 테이블 자동 세팅) |
|
||||||
|
|
||||||
|
#### 동작 흐름
|
||||||
|
1. 버튼 설정에서 액션 타입 = `"approval"` 선택
|
||||||
|
2. 대상 테이블 자동 설정 (현재 화면 테이블)
|
||||||
|
3. 버튼 클릭 시 `CustomEvent('open-approval-modal')` 발송
|
||||||
|
4. `ApprovalGlobalListener`가 수신하여 `ApprovalRequestModal` 오픈
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 멀티테넌시 적용
|
||||||
|
|
||||||
|
| 영역 | 적용 |
|
||||||
|
|------|------|
|
||||||
|
| DB 테이블 | 모든 테이블에 `company_code NOT NULL` 포함 |
|
||||||
|
| 인덱스 | `company_code` 컬럼에 인덱스 생성 |
|
||||||
|
| SELECT | `WHERE company_code = $N` 필수 |
|
||||||
|
| INSERT | `company_code` 값 포함 필수 |
|
||||||
|
| UPDATE/DELETE | `WHERE` 절에 `company_code` 조건 포함 |
|
||||||
|
| 최고관리자 | `company_code = '*'` → 모든 데이터 조회 가능 |
|
||||||
|
| JOIN | `ON` 절에 `company_code` 매칭 포함 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 전체 파일 목록
|
||||||
|
|
||||||
|
### 데이터베이스
|
||||||
|
```
|
||||||
|
db/migrations/100_create_approval_system.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
### 백엔드
|
||||||
|
```
|
||||||
|
backend-node/src/controllers/approvalController.ts
|
||||||
|
backend-node/src/routes/approvalRoutes.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
### 프론트엔드 - 결재 모달/리스너
|
||||||
|
```
|
||||||
|
frontend/components/approval/ApprovalRequestModal.tsx
|
||||||
|
frontend/components/approval/ApprovalGlobalListener.tsx
|
||||||
|
```
|
||||||
|
|
||||||
|
### 프론트엔드 - 결재함 페이지
|
||||||
|
```
|
||||||
|
frontend/app/(main)/admin/approvalBox/page.tsx
|
||||||
|
```
|
||||||
|
|
||||||
|
### 프론트엔드 - 결재 단계 컴포넌트
|
||||||
|
```
|
||||||
|
frontend/lib/registry/components/v2-approval-step/types.ts
|
||||||
|
frontend/lib/registry/components/v2-approval-step/ApprovalStepComponent.tsx
|
||||||
|
frontend/lib/registry/components/v2-approval-step/ApprovalStepConfigPanel.tsx
|
||||||
|
frontend/lib/registry/components/v2-approval-step/ApprovalStepRenderer.tsx
|
||||||
|
frontend/lib/registry/components/v2-approval-step/index.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
### 프론트엔드 - API 클라이언트
|
||||||
|
```
|
||||||
|
frontend/lib/api/approval.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
### 프론트엔드 - 버튼 액션 연동 (수정된 파일)
|
||||||
|
```
|
||||||
|
frontend/lib/utils/buttonActions.ts
|
||||||
|
frontend/lib/utils/improvedButtonActionExecutor.ts
|
||||||
|
frontend/lib/registry/components/v2-button-primary/ButtonPrimaryComponent.tsx
|
||||||
|
frontend/components/screen/config-panels/ButtonConfigPanel.tsx
|
||||||
|
```
|
||||||
|
|
||||||
|
### 프론트엔드 - 레이아웃 (수정된 파일)
|
||||||
|
```
|
||||||
|
frontend/components/layout/UserDropdown.tsx (결재함 메뉴 추가)
|
||||||
|
frontend/components/layout/AppLayout.tsx (결재함 메뉴 추가)
|
||||||
|
frontend/lib/registry/components/index.ts (v2-approval-step 렌더러 import)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 사용 방법
|
||||||
|
|
||||||
|
### 결재 버튼 추가
|
||||||
|
1. 화면 디자이너에서 버튼 컴포넌트 추가
|
||||||
|
2. 버튼 설정 > 액션 타입 = `결재` 선택
|
||||||
|
3. 대상 테이블이 자동 설정됨 (수동 변경 가능)
|
||||||
|
4. 저장
|
||||||
|
|
||||||
|
### 결재 요청하기
|
||||||
|
1. 데이터 행 선택 (선택적)
|
||||||
|
2. 결재 버튼 클릭
|
||||||
|
3. 결재 모달에서:
|
||||||
|
- 결재 제목 입력
|
||||||
|
- 결재 모드 선택 (다단 결재 / 다중 결재)
|
||||||
|
- 결재자 검색하여 추가
|
||||||
|
4. 결재 요청 클릭
|
||||||
|
|
||||||
|
### 결재 처리하기
|
||||||
|
1. 프로필 드롭다운 > 결재함 클릭
|
||||||
|
2. 받은 결재 탭에서 대기 중인 결재 확인
|
||||||
|
3. 상세 보기 > 승인 또는 반려
|
||||||
|
|
||||||
|
### 결재 단계 표시하기
|
||||||
|
1. 화면 디자이너에서 `결재 단계` 컴포넌트 추가
|
||||||
|
2. 설정에서 대상 테이블 / 레코드 ID 필드 선택
|
||||||
|
3. 표시 모드 (가로/세로) 및 옵션 설정
|
||||||
|
4. 저장 → 행 선택 시 해당 레코드의 결재 단계가 표시됨
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 향후 개선 사항
|
||||||
|
|
||||||
|
- [ ] 결재 알림 (실시간 알림, 이메일 연동)
|
||||||
|
- [ ] 제어관리 시스템 연동 (결재 완료 후 자동 액션)
|
||||||
|
- [ ] 결재 위임 기능
|
||||||
|
- [ ] 결재 이력 조회 / 통계 대시보드
|
||||||
|
- [ ] 결재선 즐겨찾기 (자주 쓰는 결재선 저장)
|
||||||
|
- [ ] 모바일 결재 처리 최적화
|
||||||
|
|
@ -0,0 +1,419 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useCallback } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import {
|
||||||
|
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import {
|
||||||
|
Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import {
|
||||||
|
Loader2, Send, Inbox, CheckCircle, XCircle, Clock, Eye,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { ScrollToTop } from "@/components/common/ScrollToTop";
|
||||||
|
import {
|
||||||
|
getApprovalRequests,
|
||||||
|
getApprovalRequest,
|
||||||
|
getMyPendingApprovals,
|
||||||
|
processApprovalLine,
|
||||||
|
cancelApprovalRequest,
|
||||||
|
type ApprovalRequest,
|
||||||
|
type ApprovalLine,
|
||||||
|
} from "@/lib/api/approval";
|
||||||
|
|
||||||
|
const STATUS_MAP: Record<string, { label: string; variant: "default" | "secondary" | "destructive" | "outline" }> = {
|
||||||
|
requested: { label: "요청", variant: "outline" },
|
||||||
|
in_progress: { label: "진행중", variant: "default" },
|
||||||
|
approved: { label: "승인", variant: "default" },
|
||||||
|
rejected: { label: "반려", variant: "destructive" },
|
||||||
|
cancelled: { label: "회수", variant: "secondary" },
|
||||||
|
waiting: { label: "대기", variant: "outline" },
|
||||||
|
pending: { label: "결재대기", variant: "default" },
|
||||||
|
skipped: { label: "건너뜀", variant: "secondary" },
|
||||||
|
};
|
||||||
|
|
||||||
|
function StatusBadge({ status }: { status: string }) {
|
||||||
|
const info = STATUS_MAP[status] || { label: status, variant: "outline" as const };
|
||||||
|
return <Badge variant={info.variant}>{info.label}</Badge>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(dateStr?: string) {
|
||||||
|
if (!dateStr) return "-";
|
||||||
|
return new Date(dateStr).toLocaleDateString("ko-KR", {
|
||||||
|
year: "numeric", month: "2-digit", day: "2-digit",
|
||||||
|
hour: "2-digit", minute: "2-digit",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 상신함 (내가 올린 결재)
|
||||||
|
// ============================================================
|
||||||
|
function SentTab() {
|
||||||
|
const [requests, setRequests] = useState<ApprovalRequest[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [detailOpen, setDetailOpen] = useState(false);
|
||||||
|
const [selectedRequest, setSelectedRequest] = useState<ApprovalRequest | null>(null);
|
||||||
|
const [detailLoading, setDetailLoading] = useState(false);
|
||||||
|
|
||||||
|
const fetchRequests = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
const res = await getApprovalRequests({ my_approvals: false });
|
||||||
|
if (res.success && res.data) setRequests(res.data);
|
||||||
|
setLoading(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => { fetchRequests(); }, [fetchRequests]);
|
||||||
|
|
||||||
|
const openDetail = async (req: ApprovalRequest) => {
|
||||||
|
setDetailLoading(true);
|
||||||
|
setDetailOpen(true);
|
||||||
|
const res = await getApprovalRequest(req.request_id);
|
||||||
|
if (res.success && res.data) {
|
||||||
|
setSelectedRequest(res.data);
|
||||||
|
} else {
|
||||||
|
setSelectedRequest(req);
|
||||||
|
}
|
||||||
|
setDetailLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = async () => {
|
||||||
|
if (!selectedRequest) return;
|
||||||
|
const res = await cancelApprovalRequest(selectedRequest.request_id);
|
||||||
|
if (res.success) {
|
||||||
|
toast.success("결재가 회수되었습니다.");
|
||||||
|
setDetailOpen(false);
|
||||||
|
fetchRequests();
|
||||||
|
} else {
|
||||||
|
toast.error(res.error || "회수 실패");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex h-64 items-center justify-center">
|
||||||
|
<Loader2 className="text-muted-foreground h-6 w-6 animate-spin" />
|
||||||
|
</div>
|
||||||
|
) : requests.length === 0 ? (
|
||||||
|
<div className="bg-card flex h-64 flex-col items-center justify-center rounded-lg border shadow-sm">
|
||||||
|
<Send className="text-muted-foreground mb-2 h-8 w-8" />
|
||||||
|
<p className="text-muted-foreground text-sm">상신한 결재가 없습니다.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="bg-card rounded-lg border shadow-sm">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow className="bg-muted/50 border-b hover:bg-muted/50">
|
||||||
|
<TableHead className="h-12 text-sm font-semibold">제목</TableHead>
|
||||||
|
<TableHead className="h-12 text-sm font-semibold">대상 테이블</TableHead>
|
||||||
|
<TableHead className="h-12 w-[100px] text-center text-sm font-semibold">진행</TableHead>
|
||||||
|
<TableHead className="h-12 w-[80px] text-center text-sm font-semibold">상태</TableHead>
|
||||||
|
<TableHead className="h-12 w-[140px] text-sm font-semibold">요청일</TableHead>
|
||||||
|
<TableHead className="h-12 w-[60px] text-center text-sm font-semibold">보기</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{requests.map((req) => (
|
||||||
|
<TableRow key={req.request_id} className="border-b transition-colors hover:bg-muted/50">
|
||||||
|
<TableCell className="h-14 text-sm font-medium">{req.title}</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground h-14 text-sm">{req.target_table}</TableCell>
|
||||||
|
<TableCell className="h-14 text-center text-sm">
|
||||||
|
{req.current_step}/{req.total_steps}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="h-14 text-center"><StatusBadge status={req.status} /></TableCell>
|
||||||
|
<TableCell className="text-muted-foreground h-14 text-sm">{formatDate(req.created_at)}</TableCell>
|
||||||
|
<TableCell className="h-14 text-center">
|
||||||
|
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => openDetail(req)}>
|
||||||
|
<Eye className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 상세 모달 */}
|
||||||
|
<Dialog open={detailOpen} onOpenChange={setDetailOpen}>
|
||||||
|
<DialogContent className="max-w-[95vw] sm:max-w-[560px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-base sm:text-lg">결재 상세</DialogTitle>
|
||||||
|
<DialogDescription className="text-xs sm:text-sm">
|
||||||
|
{selectedRequest?.title}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
{detailLoading ? (
|
||||||
|
<div className="flex h-32 items-center justify-center">
|
||||||
|
<Loader2 className="h-5 w-5 animate-spin" />
|
||||||
|
</div>
|
||||||
|
) : selectedRequest && (
|
||||||
|
<div className="max-h-[50vh] space-y-4 overflow-y-auto">
|
||||||
|
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground text-xs">상태</span>
|
||||||
|
<div className="mt-1"><StatusBadge status={selectedRequest.status} /></div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground text-xs">진행</span>
|
||||||
|
<p className="mt-1 font-medium">{selectedRequest.current_step}/{selectedRequest.total_steps}단계</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground text-xs">대상 테이블</span>
|
||||||
|
<p className="mt-1 font-medium">{selectedRequest.target_table}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground text-xs">요청일</span>
|
||||||
|
<p className="mt-1">{formatDate(selectedRequest.created_at)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{selectedRequest.description && (
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground text-xs">사유</span>
|
||||||
|
<p className="mt-1 text-sm">{selectedRequest.description}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* 결재선 */}
|
||||||
|
{selectedRequest.lines && selectedRequest.lines.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground text-xs">결재선</span>
|
||||||
|
<div className="mt-2 space-y-2">
|
||||||
|
{selectedRequest.lines
|
||||||
|
.sort((a, b) => a.step_order - b.step_order)
|
||||||
|
.map((line) => (
|
||||||
|
<div key={line.line_id} className="bg-muted/30 flex items-center justify-between rounded-md border p-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge variant="outline" className="text-[10px]">{line.step_order}차</Badge>
|
||||||
|
<span className="text-sm font-medium">{line.approver_name || line.approver_id}</span>
|
||||||
|
{line.approver_position && (
|
||||||
|
<span className="text-muted-foreground text-xs">({line.approver_position})</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<StatusBadge status={line.status} />
|
||||||
|
{line.processed_at && (
|
||||||
|
<span className="text-muted-foreground text-[10px]">{formatDate(line.processed_at)}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<DialogFooter className="gap-2 sm:gap-0">
|
||||||
|
{selectedRequest?.status === "requested" && (
|
||||||
|
<Button variant="destructive" onClick={handleCancel} className="h-8 text-xs sm:h-10 sm:text-sm">
|
||||||
|
결재 회수
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button variant="outline" onClick={() => setDetailOpen(false)} className="h-8 text-xs sm:h-10 sm:text-sm">
|
||||||
|
닫기
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 수신함 (내가 결재해야 할 것)
|
||||||
|
// ============================================================
|
||||||
|
function ReceivedTab() {
|
||||||
|
const [pendingLines, setPendingLines] = useState<ApprovalLine[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
const [processOpen, setProcessOpen] = useState(false);
|
||||||
|
const [selectedLine, setSelectedLine] = useState<ApprovalLine | null>(null);
|
||||||
|
const [comment, setComment] = useState("");
|
||||||
|
const [isProcessing, setIsProcessing] = useState(false);
|
||||||
|
|
||||||
|
const fetchPending = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
const res = await getMyPendingApprovals();
|
||||||
|
if (res.success && res.data) setPendingLines(res.data);
|
||||||
|
setLoading(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => { fetchPending(); }, [fetchPending]);
|
||||||
|
|
||||||
|
const openProcess = (line: ApprovalLine) => {
|
||||||
|
setSelectedLine(line);
|
||||||
|
setComment("");
|
||||||
|
setProcessOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleProcess = async (action: "approved" | "rejected") => {
|
||||||
|
if (!selectedLine) return;
|
||||||
|
setIsProcessing(true);
|
||||||
|
const res = await processApprovalLine(selectedLine.line_id, {
|
||||||
|
action,
|
||||||
|
comment: comment.trim() || undefined,
|
||||||
|
});
|
||||||
|
setIsProcessing(false);
|
||||||
|
if (res.success) {
|
||||||
|
toast.success(action === "approved" ? "승인되었습니다." : "반려되었습니다.");
|
||||||
|
setProcessOpen(false);
|
||||||
|
fetchPending();
|
||||||
|
} else {
|
||||||
|
toast.error(res.error || "처리 실패");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex h-64 items-center justify-center">
|
||||||
|
<Loader2 className="text-muted-foreground h-6 w-6 animate-spin" />
|
||||||
|
</div>
|
||||||
|
) : pendingLines.length === 0 ? (
|
||||||
|
<div className="bg-card flex h-64 flex-col items-center justify-center rounded-lg border shadow-sm">
|
||||||
|
<Inbox className="text-muted-foreground mb-2 h-8 w-8" />
|
||||||
|
<p className="text-muted-foreground text-sm">결재 대기 건이 없습니다.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="bg-card rounded-lg border shadow-sm">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow className="bg-muted/50 border-b hover:bg-muted/50">
|
||||||
|
<TableHead className="h-12 text-sm font-semibold">제목</TableHead>
|
||||||
|
<TableHead className="h-12 text-sm font-semibold">요청자</TableHead>
|
||||||
|
<TableHead className="h-12 text-sm font-semibold">대상 테이블</TableHead>
|
||||||
|
<TableHead className="h-12 w-[80px] text-center text-sm font-semibold">단계</TableHead>
|
||||||
|
<TableHead className="h-12 w-[140px] text-sm font-semibold">요청일</TableHead>
|
||||||
|
<TableHead className="h-12 w-[100px] text-center text-sm font-semibold">처리</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{pendingLines.map((line) => (
|
||||||
|
<TableRow key={line.line_id} className="border-b transition-colors hover:bg-muted/50">
|
||||||
|
<TableCell className="h-14 text-sm font-medium">{line.title || "-"}</TableCell>
|
||||||
|
<TableCell className="h-14 text-sm">
|
||||||
|
{line.requester_name || "-"}
|
||||||
|
{line.requester_dept && (
|
||||||
|
<span className="text-muted-foreground ml-1 text-xs">({line.requester_dept})</span>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground h-14 text-sm">{line.target_table || "-"}</TableCell>
|
||||||
|
<TableCell className="h-14 text-center text-sm">
|
||||||
|
<Badge variant="outline">{line.step_order}차</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground h-14 text-sm">{formatDate(line.request_created_at || line.created_at)}</TableCell>
|
||||||
|
<TableCell className="h-14 text-center">
|
||||||
|
<Button size="sm" className="h-8 text-xs" onClick={() => openProcess(line)}>
|
||||||
|
결재하기
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 결재 처리 모달 */}
|
||||||
|
<Dialog open={processOpen} onOpenChange={setProcessOpen}>
|
||||||
|
<DialogContent className="max-w-[95vw] sm:max-w-[450px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-base sm:text-lg">결재 처리</DialogTitle>
|
||||||
|
<DialogDescription className="text-xs sm:text-sm">
|
||||||
|
{selectedLine?.title}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground text-xs">요청자</span>
|
||||||
|
<p className="mt-1 font-medium">{selectedLine?.requester_name || "-"}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground text-xs">결재 단계</span>
|
||||||
|
<p className="mt-1 font-medium">{selectedLine?.step_order}차 결재</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs sm:text-sm">의견</Label>
|
||||||
|
<Textarea
|
||||||
|
value={comment}
|
||||||
|
onChange={(e) => setComment(e.target.value)}
|
||||||
|
placeholder="결재 의견을 입력하세요 (선택사항)"
|
||||||
|
className="min-h-[80px] text-xs sm:text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter className="gap-2 sm:gap-0">
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => handleProcess("rejected")}
|
||||||
|
disabled={isProcessing}
|
||||||
|
className="h-8 flex-1 gap-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||||
|
>
|
||||||
|
<XCircle className="h-4 w-4" />
|
||||||
|
반려
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => handleProcess("approved")}
|
||||||
|
disabled={isProcessing}
|
||||||
|
className="h-8 flex-1 gap-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||||
|
>
|
||||||
|
{isProcessing ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<CheckCircle className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
승인
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 메인 페이지
|
||||||
|
// ============================================================
|
||||||
|
export default function ApprovalBoxPage() {
|
||||||
|
return (
|
||||||
|
<div className="bg-background flex min-h-screen flex-col">
|
||||||
|
<div className="space-y-6 p-6">
|
||||||
|
<div className="space-y-2 border-b pb-4">
|
||||||
|
<h1 className="text-3xl font-bold tracking-tight">결재함</h1>
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
내가 상신한 결재와 나에게 온 결재를 관리합니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Tabs defaultValue="received" className="space-y-4">
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="received" className="gap-2">
|
||||||
|
<Inbox className="h-4 w-4" />
|
||||||
|
수신함 (결재 대기)
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="sent" className="gap-2">
|
||||||
|
<Send className="h-4 w-4" />
|
||||||
|
상신함 (내가 올린)
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="received">
|
||||||
|
<ReceivedTab />
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="sent">
|
||||||
|
<SentTab />
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ScrollToTop />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 54 KiB |
|
|
@ -41,12 +41,16 @@ interface ApprovalRequestModalProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UserSearchResult {
|
interface UserSearchResult {
|
||||||
user_id: string;
|
userId: string;
|
||||||
user_name: string;
|
userName: string;
|
||||||
|
positionName?: string;
|
||||||
|
deptName?: string;
|
||||||
|
deptCode?: string;
|
||||||
|
email?: string;
|
||||||
|
user_id?: string;
|
||||||
|
user_name?: string;
|
||||||
position_name?: string;
|
position_name?: string;
|
||||||
dept_name?: string;
|
dept_name?: string;
|
||||||
dept_code?: string;
|
|
||||||
email?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function genId(): string {
|
function genId(): string {
|
||||||
|
|
@ -98,10 +102,17 @@ export const ApprovalRequestModal: React.FC<ApprovalRequestModalProps> = ({
|
||||||
try {
|
try {
|
||||||
const res = await getUserList({ search: query.trim(), limit: 20 });
|
const res = await getUserList({ search: query.trim(), limit: 20 });
|
||||||
const data = res?.data || res || [];
|
const data = res?.data || res || [];
|
||||||
const users: UserSearchResult[] = Array.isArray(data) ? data : [];
|
const rawUsers: any[] = Array.isArray(data) ? data : [];
|
||||||
// 이미 추가된 결재자 제외
|
const users: UserSearchResult[] = rawUsers.map((u: any) => ({
|
||||||
|
userId: u.userId || u.user_id || "",
|
||||||
|
userName: u.userName || u.user_name || "",
|
||||||
|
positionName: u.positionName || u.position_name || "",
|
||||||
|
deptName: u.deptName || u.dept_name || "",
|
||||||
|
deptCode: u.deptCode || u.dept_code || "",
|
||||||
|
email: u.email || "",
|
||||||
|
}));
|
||||||
const existingIds = new Set(approvers.map((a) => a.user_id));
|
const existingIds = new Set(approvers.map((a) => a.user_id));
|
||||||
setSearchResults(users.filter((u) => !existingIds.has(u.user_id)));
|
setSearchResults(users.filter((u) => u.userId && !existingIds.has(u.userId)));
|
||||||
} catch {
|
} catch {
|
||||||
setSearchResults([]);
|
setSearchResults([]);
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -128,10 +139,10 @@ export const ApprovalRequestModal: React.FC<ApprovalRequestModalProps> = ({
|
||||||
...prev,
|
...prev,
|
||||||
{
|
{
|
||||||
id: genId(),
|
id: genId(),
|
||||||
user_id: user.user_id,
|
user_id: user.userId,
|
||||||
user_name: user.user_name,
|
user_name: user.userName,
|
||||||
position_name: user.position_name || "",
|
position_name: user.positionName || "",
|
||||||
dept_name: user.dept_name || "",
|
dept_name: user.deptName || "",
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
setSearchQuery("");
|
setSearchQuery("");
|
||||||
|
|
@ -162,8 +173,8 @@ export const ApprovalRequestModal: React.FC<ApprovalRequestModalProps> = ({
|
||||||
setError("결재자를 1명 이상 추가해주세요.");
|
setError("결재자를 1명 이상 추가해주세요.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!eventDetail?.targetTable || !eventDetail?.targetRecordId) {
|
if (!eventDetail?.targetTable) {
|
||||||
setError("결재 대상 정보가 없습니다. 레코드를 선택 후 다시 시도해주세요.");
|
setError("결재 대상 테이블 정보가 없습니다. 버튼 설정을 확인해주세요.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -174,11 +185,9 @@ export const ApprovalRequestModal: React.FC<ApprovalRequestModalProps> = ({
|
||||||
title: title.trim(),
|
title: title.trim(),
|
||||||
description: description.trim() || undefined,
|
description: description.trim() || undefined,
|
||||||
target_table: eventDetail.targetTable,
|
target_table: eventDetail.targetTable,
|
||||||
target_record_id: eventDetail.targetRecordId,
|
target_record_id: eventDetail.targetRecordId || undefined,
|
||||||
target_record_data: {
|
target_record_data: eventDetail.targetRecordData,
|
||||||
...eventDetail.targetRecordData,
|
|
||||||
approval_mode: approvalMode,
|
approval_mode: approvalMode,
|
||||||
},
|
|
||||||
screen_id: eventDetail.screenId,
|
screen_id: eventDetail.screenId,
|
||||||
button_component_id: eventDetail.buttonComponentId,
|
button_component_id: eventDetail.buttonComponentId,
|
||||||
approvers: approvers.map((a, idx) => ({
|
approvers: approvers.map((a, idx) => ({
|
||||||
|
|
@ -321,7 +330,7 @@ export const ApprovalRequestModal: React.FC<ApprovalRequestModalProps> = ({
|
||||||
<div className="max-h-48 overflow-y-auto">
|
<div className="max-h-48 overflow-y-auto">
|
||||||
{searchResults.map((user) => (
|
{searchResults.map((user) => (
|
||||||
<button
|
<button
|
||||||
key={user.user_id}
|
key={user.userId}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => addApprover(user)}
|
onClick={() => addApprover(user)}
|
||||||
className="flex w-full items-center gap-3 px-3 py-2 text-left transition-colors hover:bg-accent"
|
className="flex w-full items-center gap-3 px-3 py-2 text-left transition-colors hover:bg-accent"
|
||||||
|
|
@ -331,13 +340,13 @@ export const ApprovalRequestModal: React.FC<ApprovalRequestModalProps> = ({
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<p className="truncate text-xs font-medium sm:text-sm">
|
<p className="truncate text-xs font-medium sm:text-sm">
|
||||||
{user.user_name}
|
{user.userName}
|
||||||
<span className="text-muted-foreground ml-1 text-[10px]">
|
<span className="text-muted-foreground ml-1 text-[10px]">
|
||||||
({user.user_id})
|
({user.userId})
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
<p className="text-muted-foreground truncate text-[10px]">
|
<p className="text-muted-foreground truncate text-[10px]">
|
||||||
{[user.dept_name, user.position_name].filter(Boolean).join(" / ") || "-"}
|
{[user.deptName, user.positionName].filter(Boolean).join(" / ") || "-"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Plus className="text-muted-foreground h-4 w-4 shrink-0" />
|
<Plus className="text-muted-foreground h-4 w-4 shrink-0" />
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ import {
|
||||||
LogOut,
|
LogOut,
|
||||||
User,
|
User,
|
||||||
Building2,
|
Building2,
|
||||||
|
FileCheck,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useMenu } from "@/contexts/MenuContext";
|
import { useMenu } from "@/contexts/MenuContext";
|
||||||
import { useAuth } from "@/hooks/useAuth";
|
import { useAuth } from "@/hooks/useAuth";
|
||||||
|
|
@ -524,6 +525,11 @@ function AppLayoutInner({ children }: AppLayoutProps) {
|
||||||
<User className="mr-2 h-4 w-4" />
|
<User className="mr-2 h-4 w-4" />
|
||||||
<span>프로필</span>
|
<span>프로필</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => router.push("/admin/approvalBox")}>
|
||||||
|
<FileCheck className="mr-2 h-4 w-4" />
|
||||||
|
<span>결재함</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem onClick={handleLogout}>
|
<DropdownMenuItem onClick={handleLogout}>
|
||||||
<LogOut className="mr-2 h-4 w-4" />
|
<LogOut className="mr-2 h-4 w-4" />
|
||||||
<span>로그아웃</span>
|
<span>로그아웃</span>
|
||||||
|
|
@ -692,6 +698,11 @@ function AppLayoutInner({ children }: AppLayoutProps) {
|
||||||
<User className="mr-2 h-4 w-4" />
|
<User className="mr-2 h-4 w-4" />
|
||||||
<span>프로필</span>
|
<span>프로필</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => router.push("/admin/approvalBox")}>
|
||||||
|
<FileCheck className="mr-2 h-4 w-4" />
|
||||||
|
<span>결재함</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem onClick={handleLogout}>
|
<DropdownMenuItem onClick={handleLogout}>
|
||||||
<LogOut className="mr-2 h-4 w-4" />
|
<LogOut className="mr-2 h-4 w-4" />
|
||||||
<span>로그아웃</span>
|
<span>로그아웃</span>
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,8 @@ import {
|
||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { LogOut, User } from "lucide-react";
|
import { LogOut, User, FileCheck } from "lucide-react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
interface UserDropdownProps {
|
interface UserDropdownProps {
|
||||||
user: any;
|
user: any;
|
||||||
|
|
@ -20,6 +21,8 @@ interface UserDropdownProps {
|
||||||
* 사용자 드롭다운 메뉴 컴포넌트
|
* 사용자 드롭다운 메뉴 컴포넌트
|
||||||
*/
|
*/
|
||||||
export function UserDropdown({ user, onProfileClick, onLogout }: UserDropdownProps) {
|
export function UserDropdown({ user, onProfileClick, onLogout }: UserDropdownProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
if (!user) return null;
|
if (!user) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -79,6 +82,11 @@ export function UserDropdown({ user, onProfileClick, onLogout }: UserDropdownPro
|
||||||
<User className="mr-2 h-4 w-4" />
|
<User className="mr-2 h-4 w-4" />
|
||||||
<span>프로필</span>
|
<span>프로필</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => router.push("/admin/approvalBox")}>
|
||||||
|
<FileCheck className="mr-2 h-4 w-4" />
|
||||||
|
<span>결재함</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem onClick={onLogout}>
|
<DropdownMenuItem onClick={onLogout}>
|
||||||
<LogOut className="mr-2 h-4 w-4" />
|
<LogOut className="mr-2 h-4 w-4" />
|
||||||
<span>로그아웃</span>
|
<span>로그아웃</span>
|
||||||
|
|
|
||||||
|
|
@ -577,8 +577,10 @@ export const OptimizedButtonComponent: React.FC<OptimizedButtonProps> = ({
|
||||||
const getActionDisplayName = (actionType: ButtonActionType): string => {
|
const getActionDisplayName = (actionType: ButtonActionType): string => {
|
||||||
const displayNames: Record<ButtonActionType, string> = {
|
const displayNames: Record<ButtonActionType, string> = {
|
||||||
save: "저장",
|
save: "저장",
|
||||||
|
cancel: "취소",
|
||||||
delete: "삭제",
|
delete: "삭제",
|
||||||
edit: "수정",
|
edit: "수정",
|
||||||
|
copy: "복사",
|
||||||
add: "추가",
|
add: "추가",
|
||||||
search: "검색",
|
search: "검색",
|
||||||
reset: "초기화",
|
reset: "초기화",
|
||||||
|
|
@ -589,6 +591,9 @@ export const OptimizedButtonComponent: React.FC<OptimizedButtonProps> = ({
|
||||||
newWindow: "새 창",
|
newWindow: "새 창",
|
||||||
navigate: "페이지 이동",
|
navigate: "페이지 이동",
|
||||||
control: "제어",
|
control: "제어",
|
||||||
|
transferData: "데이터 전달",
|
||||||
|
quickInsert: "즉시 저장",
|
||||||
|
approval: "결재",
|
||||||
};
|
};
|
||||||
return displayNames[actionType] || actionType;
|
return displayNames[actionType] || actionType;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -152,10 +152,11 @@ export interface CreateApprovalRequestInput {
|
||||||
description?: string;
|
description?: string;
|
||||||
definition_id?: number;
|
definition_id?: number;
|
||||||
target_table: string;
|
target_table: string;
|
||||||
target_record_id: string;
|
target_record_id?: string;
|
||||||
target_record_data?: Record<string, any>;
|
target_record_data?: Record<string, any>;
|
||||||
screen_id?: number;
|
screen_id?: number;
|
||||||
button_component_id?: string;
|
button_component_id?: string;
|
||||||
|
approval_mode?: "sequential" | "parallel";
|
||||||
approvers: {
|
approvers: {
|
||||||
approver_id: string;
|
approver_id: string;
|
||||||
approver_name?: string;
|
approver_name?: string;
|
||||||
|
|
@ -351,6 +352,7 @@ export async function deleteApprovalTemplate(id: number): Promise<ApiResponse<vo
|
||||||
export async function getApprovalRequests(params?: {
|
export async function getApprovalRequests(params?: {
|
||||||
status?: string;
|
status?: string;
|
||||||
target_table?: string;
|
target_table?: string;
|
||||||
|
target_record_id?: string;
|
||||||
requester_id?: string;
|
requester_id?: string;
|
||||||
my_approvals?: boolean;
|
my_approvals?: boolean;
|
||||||
page?: number;
|
page?: number;
|
||||||
|
|
@ -360,6 +362,7 @@ export async function getApprovalRequests(params?: {
|
||||||
const qs = new URLSearchParams();
|
const qs = new URLSearchParams();
|
||||||
if (params?.status) qs.append("status", params.status);
|
if (params?.status) qs.append("status", params.status);
|
||||||
if (params?.target_table) qs.append("target_table", params.target_table);
|
if (params?.target_table) qs.append("target_table", params.target_table);
|
||||||
|
if (params?.target_record_id) qs.append("target_record_id", params.target_record_id);
|
||||||
if (params?.requester_id) qs.append("requester_id", params.requester_id);
|
if (params?.requester_id) qs.append("requester_id", params.requester_id);
|
||||||
if (params?.my_approvals !== undefined) qs.append("my_approvals", String(params.my_approvals));
|
if (params?.my_approvals !== undefined) qs.append("my_approvals", String(params.my_approvals));
|
||||||
if (params?.page) qs.append("page", String(params.page));
|
if (params?.page) qs.append("page", String(params.page));
|
||||||
|
|
|
||||||
|
|
@ -117,6 +117,7 @@ import "./v2-item-routing/ItemRoutingRenderer"; // 품목별 라우팅
|
||||||
import "./v2-split-line/SplitLineRenderer"; // V2 캔버스 분할선
|
import "./v2-split-line/SplitLineRenderer"; // V2 캔버스 분할선
|
||||||
import "./v2-bom-tree/BomTreeRenderer"; // BOM 트리 뷰
|
import "./v2-bom-tree/BomTreeRenderer"; // BOM 트리 뷰
|
||||||
import "./v2-bom-item-editor/BomItemEditorRenderer"; // BOM 하위품목 편집기
|
import "./v2-bom-item-editor/BomItemEditorRenderer"; // BOM 하위품목 편집기
|
||||||
|
import "./v2-approval-step/ApprovalStepRenderer"; // 결재 단계 시각화
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 컴포넌트 초기화 함수
|
* 컴포넌트 초기화 함수
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,530 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useEffect, useState, useCallback } from "react";
|
||||||
|
import { ComponentRendererProps } from "@/types/component";
|
||||||
|
import { ApprovalStepConfig } from "./types";
|
||||||
|
import { filterDOMProps } from "@/lib/utils/domPropsFilter";
|
||||||
|
import {
|
||||||
|
getApprovalRequests,
|
||||||
|
getApprovalRequest,
|
||||||
|
type ApprovalRequest,
|
||||||
|
type ApprovalLine,
|
||||||
|
} from "@/lib/api/approval";
|
||||||
|
import {
|
||||||
|
Check,
|
||||||
|
X,
|
||||||
|
Clock,
|
||||||
|
SkipForward,
|
||||||
|
Loader2,
|
||||||
|
FileCheck,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronUp,
|
||||||
|
ArrowRight,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export interface ApprovalStepComponentProps extends ComponentRendererProps {}
|
||||||
|
|
||||||
|
interface ApprovalStepData {
|
||||||
|
request: ApprovalRequest;
|
||||||
|
lines: ApprovalLine[];
|
||||||
|
approvalMode: "sequential" | "parallel";
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_CONFIG = {
|
||||||
|
approved: {
|
||||||
|
label: "승인",
|
||||||
|
icon: Check,
|
||||||
|
bgColor: "bg-emerald-100",
|
||||||
|
borderColor: "border-emerald-500",
|
||||||
|
textColor: "text-emerald-700",
|
||||||
|
iconColor: "text-emerald-600",
|
||||||
|
dotColor: "bg-emerald-500",
|
||||||
|
},
|
||||||
|
rejected: {
|
||||||
|
label: "반려",
|
||||||
|
icon: X,
|
||||||
|
bgColor: "bg-rose-100",
|
||||||
|
borderColor: "border-rose-500",
|
||||||
|
textColor: "text-rose-700",
|
||||||
|
iconColor: "text-rose-600",
|
||||||
|
dotColor: "bg-rose-500",
|
||||||
|
},
|
||||||
|
pending: {
|
||||||
|
label: "결재 대기",
|
||||||
|
icon: Clock,
|
||||||
|
bgColor: "bg-amber-50",
|
||||||
|
borderColor: "border-amber-400",
|
||||||
|
textColor: "text-amber-700",
|
||||||
|
iconColor: "text-amber-500",
|
||||||
|
dotColor: "bg-amber-400",
|
||||||
|
},
|
||||||
|
waiting: {
|
||||||
|
label: "대기",
|
||||||
|
icon: Clock,
|
||||||
|
bgColor: "bg-muted",
|
||||||
|
borderColor: "border-border",
|
||||||
|
textColor: "text-muted-foreground",
|
||||||
|
iconColor: "text-muted-foreground",
|
||||||
|
dotColor: "bg-muted-foreground/40",
|
||||||
|
},
|
||||||
|
skipped: {
|
||||||
|
label: "건너뜀",
|
||||||
|
icon: SkipForward,
|
||||||
|
bgColor: "bg-muted/50",
|
||||||
|
borderColor: "border-border/50",
|
||||||
|
textColor: "text-muted-foreground/70",
|
||||||
|
iconColor: "text-muted-foreground/50",
|
||||||
|
dotColor: "bg-muted-foreground/30",
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const REQUEST_STATUS_CONFIG = {
|
||||||
|
requested: { label: "요청됨", color: "text-blue-600", bg: "bg-blue-50" },
|
||||||
|
in_progress: { label: "진행 중", color: "text-amber-600", bg: "bg-amber-50" },
|
||||||
|
approved: { label: "승인 완료", color: "text-emerald-600", bg: "bg-emerald-50" },
|
||||||
|
rejected: { label: "반려", color: "text-rose-600", bg: "bg-rose-50" },
|
||||||
|
cancelled: { label: "취소", color: "text-muted-foreground", bg: "bg-muted" },
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 결재 단계 시각화 컴포넌트
|
||||||
|
* 결재 요청의 각 단계별 상태를 스테퍼 형태로 표시
|
||||||
|
*/
|
||||||
|
export const ApprovalStepComponent: React.FC<ApprovalStepComponentProps> = ({
|
||||||
|
component,
|
||||||
|
isDesignMode = false,
|
||||||
|
isSelected = false,
|
||||||
|
onClick,
|
||||||
|
onDragStart,
|
||||||
|
onDragEnd,
|
||||||
|
formData,
|
||||||
|
...props
|
||||||
|
}) => {
|
||||||
|
const componentConfig = (component.componentConfig || {}) as ApprovalStepConfig;
|
||||||
|
const {
|
||||||
|
targetTable,
|
||||||
|
targetRecordIdField,
|
||||||
|
displayMode = "horizontal",
|
||||||
|
showComment = true,
|
||||||
|
showTimestamp = true,
|
||||||
|
showDept = true,
|
||||||
|
compact = false,
|
||||||
|
} = componentConfig;
|
||||||
|
|
||||||
|
const [stepData, setStepData] = useState<ApprovalStepData | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const targetRecordId = targetRecordIdField && formData
|
||||||
|
? String(formData[targetRecordIdField] || "")
|
||||||
|
: "";
|
||||||
|
|
||||||
|
const fetchApprovalData = useCallback(async () => {
|
||||||
|
if (isDesignMode || !targetTable || !targetRecordId) return;
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await getApprovalRequests({
|
||||||
|
target_table: targetTable,
|
||||||
|
target_record_id: targetRecordId,
|
||||||
|
limit: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.success && res.data && res.data.length > 0) {
|
||||||
|
const latestRequest = res.data[0];
|
||||||
|
const detailRes = await getApprovalRequest(latestRequest.request_id);
|
||||||
|
|
||||||
|
if (detailRes.success && detailRes.data) {
|
||||||
|
const request = detailRes.data;
|
||||||
|
const lines = request.lines || [];
|
||||||
|
const approvalMode =
|
||||||
|
(request.target_record_data?.approval_mode as "sequential" | "parallel") || "sequential";
|
||||||
|
|
||||||
|
setStepData({ request, lines, approvalMode });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setStepData(null);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError("결재 정보를 불러올 수 없습니다.");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [isDesignMode, targetTable, targetRecordId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchApprovalData();
|
||||||
|
}, [fetchApprovalData]);
|
||||||
|
|
||||||
|
// 디자인 모드용 샘플 데이터
|
||||||
|
useEffect(() => {
|
||||||
|
if (isDesignMode) {
|
||||||
|
setStepData({
|
||||||
|
request: {
|
||||||
|
request_id: 0,
|
||||||
|
title: "결재 요청 샘플",
|
||||||
|
target_table: "sample_table",
|
||||||
|
target_record_id: "1",
|
||||||
|
status: "in_progress",
|
||||||
|
current_step: 2,
|
||||||
|
total_steps: 3,
|
||||||
|
requester_id: "admin",
|
||||||
|
requester_name: "홍길동",
|
||||||
|
requester_dept: "개발팀",
|
||||||
|
company_code: "SAMPLE",
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
lines: [
|
||||||
|
{
|
||||||
|
line_id: 1, request_id: 0, step_order: 1,
|
||||||
|
approver_id: "user1", approver_name: "김부장", approver_position: "부장", approver_dept: "경영지원팀",
|
||||||
|
status: "approved", comment: "확인했습니다.",
|
||||||
|
processed_at: new Date(Date.now() - 86400000).toISOString(),
|
||||||
|
company_code: "SAMPLE", created_at: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
line_id: 2, request_id: 0, step_order: 2,
|
||||||
|
approver_id: "user2", approver_name: "이과장", approver_position: "과장", approver_dept: "기획팀",
|
||||||
|
status: "pending",
|
||||||
|
company_code: "SAMPLE", created_at: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
line_id: 3, request_id: 0, step_order: 3,
|
||||||
|
approver_id: "user3", approver_name: "박대리", approver_position: "대리", approver_dept: "개발팀",
|
||||||
|
status: "waiting",
|
||||||
|
company_code: "SAMPLE", created_at: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
approvalMode: "sequential",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [isDesignMode]);
|
||||||
|
|
||||||
|
const componentStyle: React.CSSProperties = {
|
||||||
|
position: "absolute",
|
||||||
|
left: `${component.style?.positionX || 0}px`,
|
||||||
|
top: `${component.style?.positionY || 0}px`,
|
||||||
|
width: `${component.style?.width || 500}px`,
|
||||||
|
height: "auto",
|
||||||
|
minHeight: `${component.style?.height || 80}px`,
|
||||||
|
zIndex: component.style?.positionZ || 1,
|
||||||
|
cursor: isDesignMode ? "pointer" : "default",
|
||||||
|
border: isSelected ? "2px solid #3b82f6" : "none",
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClick = (e: React.MouseEvent) => {
|
||||||
|
if (isDesignMode) {
|
||||||
|
e.stopPropagation();
|
||||||
|
onClick?.(e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const domProps = filterDOMProps(props);
|
||||||
|
|
||||||
|
const formatDate = (dateStr?: string | null) => {
|
||||||
|
if (!dateStr) return "";
|
||||||
|
const d = new Date(dateStr);
|
||||||
|
return `${d.getMonth() + 1}/${d.getDate()} ${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isDesignMode && !targetTable) {
|
||||||
|
return (
|
||||||
|
<div style={componentStyle} onClick={handleClick} {...domProps}>
|
||||||
|
<div className="flex items-center justify-center rounded-md border border-dashed border-border p-4 text-xs text-muted-foreground">
|
||||||
|
대상 테이블이 설정되지 않았습니다.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div style={componentStyle} onClick={handleClick} {...domProps}>
|
||||||
|
<div className="flex items-center justify-center gap-2 p-3 text-xs text-muted-foreground">
|
||||||
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||||
|
결재 정보 로딩 중...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div style={componentStyle} onClick={handleClick} {...domProps}>
|
||||||
|
<div className="rounded-md border border-destructive/30 bg-destructive/5 p-3 text-xs text-destructive">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!stepData) {
|
||||||
|
if (!isDesignMode && !targetRecordId) {
|
||||||
|
return (
|
||||||
|
<div style={componentStyle} onClick={handleClick} {...domProps}>
|
||||||
|
<div className="flex items-center gap-2 rounded-md border border-dashed border-border p-3 text-xs text-muted-foreground">
|
||||||
|
<FileCheck className="h-3.5 w-3.5" />
|
||||||
|
레코드를 선택하면 결재 현황이 표시됩니다.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div style={componentStyle} onClick={handleClick} {...domProps}>
|
||||||
|
<div className="flex items-center gap-2 rounded-md border border-dashed border-border p-3 text-xs text-muted-foreground">
|
||||||
|
<FileCheck className="h-3.5 w-3.5" />
|
||||||
|
결재 요청 내역이 없습니다.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { request, lines, approvalMode } = stepData;
|
||||||
|
const reqStatus = REQUEST_STATUS_CONFIG[request.status] || REQUEST_STATUS_CONFIG.requested;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={componentStyle} onClick={handleClick} {...domProps}>
|
||||||
|
<div className="rounded-md border border-border bg-card">
|
||||||
|
{/* 헤더 - 요약 */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex w-full items-center justify-between px-3 py-2 text-left transition-colors hover:bg-muted/50"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (!isDesignMode) setExpanded((prev) => !prev);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<FileCheck className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<span className="text-xs font-medium">{request.title}</span>
|
||||||
|
<span className={cn("rounded-full px-2 py-0.5 text-[10px] font-medium", reqStatus.bg, reqStatus.color)}>
|
||||||
|
{reqStatus.label}
|
||||||
|
</span>
|
||||||
|
{approvalMode === "parallel" && (
|
||||||
|
<span className="rounded-full bg-blue-50 px-2 py-0.5 text-[10px] font-medium text-blue-600">
|
||||||
|
동시결재
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-muted-foreground">
|
||||||
|
<span className="text-[10px]">
|
||||||
|
{request.current_step}/{request.total_steps}단계
|
||||||
|
</span>
|
||||||
|
{expanded ? <ChevronUp className="h-3.5 w-3.5" /> : <ChevronDown className="h-3.5 w-3.5" />}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* 스테퍼 영역 */}
|
||||||
|
<div className={cn("border-t border-border", compact ? "px-2 py-1.5" : "px-3 py-2.5")}>
|
||||||
|
{displayMode === "horizontal" ? (
|
||||||
|
<HorizontalStepper
|
||||||
|
lines={lines}
|
||||||
|
approvalMode={approvalMode}
|
||||||
|
compact={compact}
|
||||||
|
showDept={showDept}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<VerticalStepper
|
||||||
|
lines={lines}
|
||||||
|
approvalMode={approvalMode}
|
||||||
|
compact={compact}
|
||||||
|
showDept={showDept}
|
||||||
|
showComment={showComment}
|
||||||
|
showTimestamp={showTimestamp}
|
||||||
|
formatDate={formatDate}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 확장 영역 - 상세 정보 */}
|
||||||
|
{expanded && (
|
||||||
|
<div className="border-t border-border px-3 py-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center gap-4 text-[11px] text-muted-foreground">
|
||||||
|
<span>상신자: {request.requester_name || request.requester_id}</span>
|
||||||
|
{request.requester_dept && <span>부서: {request.requester_dept}</span>}
|
||||||
|
<span>상신일: {formatDate(request.created_at)}</span>
|
||||||
|
</div>
|
||||||
|
{displayMode === "horizontal" && lines.length > 0 && (
|
||||||
|
<div className="mt-1.5 space-y-1">
|
||||||
|
{lines.map((line) => {
|
||||||
|
const sc = STATUS_CONFIG[line.status] || STATUS_CONFIG.waiting;
|
||||||
|
return (
|
||||||
|
<div key={line.line_id} className="flex items-start gap-2 text-[11px]">
|
||||||
|
<span className={cn("mt-0.5 inline-block h-2 w-2 shrink-0 rounded-full", sc.dotColor)} />
|
||||||
|
<span className="font-medium">{line.approver_name || line.approver_id}</span>
|
||||||
|
<span className={cn("font-medium", sc.textColor)}>{sc.label}</span>
|
||||||
|
{showTimestamp && line.processed_at && (
|
||||||
|
<span className="text-muted-foreground">{formatDate(line.processed_at)}</span>
|
||||||
|
)}
|
||||||
|
{showComment && line.comment && (
|
||||||
|
<span className="text-muted-foreground">- {line.comment}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ========== 가로형 스테퍼 ========== */
|
||||||
|
interface StepperProps {
|
||||||
|
lines: ApprovalLine[];
|
||||||
|
approvalMode: "sequential" | "parallel";
|
||||||
|
compact: boolean;
|
||||||
|
showDept: boolean;
|
||||||
|
showComment?: boolean;
|
||||||
|
showTimestamp?: boolean;
|
||||||
|
formatDate?: (d?: string | null) => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const HorizontalStepper: React.FC<StepperProps> = ({ lines, approvalMode, compact, showDept }) => {
|
||||||
|
if (lines.length === 0) {
|
||||||
|
return <div className="py-1 text-center text-[11px] text-muted-foreground">결재선 없음</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-0 overflow-x-auto">
|
||||||
|
{lines.map((line, idx) => {
|
||||||
|
const sc = STATUS_CONFIG[line.status] || STATUS_CONFIG.waiting;
|
||||||
|
const StatusIcon = sc.icon;
|
||||||
|
const isLast = idx === lines.length - 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<React.Fragment key={line.line_id}>
|
||||||
|
<div className="flex shrink-0 flex-col items-center gap-0.5">
|
||||||
|
{/* 아이콘 원 */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex items-center justify-center rounded-full border-2 transition-all",
|
||||||
|
sc.bgColor,
|
||||||
|
sc.borderColor,
|
||||||
|
compact ? "h-6 w-6" : "h-8 w-8"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<StatusIcon className={cn(sc.iconColor, compact ? "h-3 w-3" : "h-4 w-4")} />
|
||||||
|
</div>
|
||||||
|
{/* 결재자 이름 */}
|
||||||
|
<span className={cn("max-w-[60px] truncate text-center font-medium", compact ? "text-[9px]" : "text-[11px]")}>
|
||||||
|
{line.approver_name || line.approver_id}
|
||||||
|
</span>
|
||||||
|
{/* 직급/부서 */}
|
||||||
|
{showDept && !compact && (line.approver_position || line.approver_dept) && (
|
||||||
|
<span className="max-w-[70px] truncate text-center text-[9px] text-muted-foreground">
|
||||||
|
{line.approver_position || line.approver_dept}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 연결선 */}
|
||||||
|
{!isLast && (
|
||||||
|
<div className="mx-1 flex shrink-0 items-center">
|
||||||
|
{approvalMode === "parallel" ? (
|
||||||
|
<div className="flex h-[1px] w-4 items-center border-t border-dashed border-muted-foreground/40" />
|
||||||
|
) : (
|
||||||
|
<ArrowRight className="h-3 w-3 text-muted-foreground/40" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ========== 세로형 스테퍼 ========== */
|
||||||
|
const VerticalStepper: React.FC<StepperProps> = ({
|
||||||
|
lines,
|
||||||
|
approvalMode,
|
||||||
|
compact,
|
||||||
|
showDept,
|
||||||
|
showComment,
|
||||||
|
showTimestamp,
|
||||||
|
formatDate,
|
||||||
|
}) => {
|
||||||
|
if (lines.length === 0) {
|
||||||
|
return <div className="py-1 text-center text-[11px] text-muted-foreground">결재선 없음</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-0">
|
||||||
|
{lines.map((line, idx) => {
|
||||||
|
const sc = STATUS_CONFIG[line.status] || STATUS_CONFIG.waiting;
|
||||||
|
const StatusIcon = sc.icon;
|
||||||
|
const isLast = idx === lines.length - 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={line.line_id} className="flex gap-3">
|
||||||
|
{/* 타임라인 바 */}
|
||||||
|
<div className="flex flex-col items-center">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex shrink-0 items-center justify-center rounded-full border-2",
|
||||||
|
sc.bgColor,
|
||||||
|
sc.borderColor,
|
||||||
|
compact ? "h-5 w-5" : "h-7 w-7"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<StatusIcon className={cn(sc.iconColor, compact ? "h-2.5 w-2.5" : "h-3.5 w-3.5")} />
|
||||||
|
</div>
|
||||||
|
{!isLast && (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"w-[2px] flex-1",
|
||||||
|
approvalMode === "parallel"
|
||||||
|
? "border-l border-dashed border-muted-foreground/30"
|
||||||
|
: "bg-muted-foreground/20",
|
||||||
|
compact ? "min-h-[12px]" : "min-h-[20px]"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 결재자 정보 */}
|
||||||
|
<div className={cn("pb-2", compact ? "pb-1" : "pb-3")}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className={cn("font-medium", compact ? "text-[10px]" : "text-xs")}>
|
||||||
|
{line.approver_name || line.approver_id}
|
||||||
|
</span>
|
||||||
|
{showDept && (line.approver_position || line.approver_dept) && (
|
||||||
|
<span className="text-[10px] text-muted-foreground">
|
||||||
|
{[line.approver_position, line.approver_dept].filter(Boolean).join(" / ")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className={cn("rounded px-1.5 py-0.5 text-[9px] font-medium", sc.bgColor, sc.textColor)}>
|
||||||
|
{sc.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{showTimestamp && line.processed_at && formatDate && (
|
||||||
|
<div className="mt-0.5 text-[10px] text-muted-foreground">
|
||||||
|
{formatDate(line.processed_at)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{showComment && line.comment && (
|
||||||
|
<div className="mt-0.5 text-[10px] text-muted-foreground">
|
||||||
|
"{line.comment}"
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ApprovalStepWrapper: React.FC<ApprovalStepComponentProps> = (props) => {
|
||||||
|
return <ApprovalStepComponent {...props} />;
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,369 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useMemo, useCallback } from "react";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from "@/components/ui/popover";
|
||||||
|
import {
|
||||||
|
Command,
|
||||||
|
CommandEmpty,
|
||||||
|
CommandGroup,
|
||||||
|
CommandInput,
|
||||||
|
CommandItem,
|
||||||
|
CommandList,
|
||||||
|
} from "@/components/ui/command";
|
||||||
|
import { Check, ChevronsUpDown, Table2 } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { tableTypeApi } from "@/lib/api/screen";
|
||||||
|
import { tableManagementApi } from "@/lib/api/tableManagement";
|
||||||
|
import { ApprovalStepConfig } from "./types";
|
||||||
|
|
||||||
|
export interface ApprovalStepConfigPanelProps {
|
||||||
|
config: ApprovalStepConfig;
|
||||||
|
onChange: (config: Partial<ApprovalStepConfig>) => void;
|
||||||
|
tables?: any[];
|
||||||
|
allTables?: any[];
|
||||||
|
screenTableName?: string;
|
||||||
|
tableColumns?: any[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ApprovalStepConfigPanel: React.FC<ApprovalStepConfigPanelProps> = ({
|
||||||
|
config,
|
||||||
|
onChange,
|
||||||
|
screenTableName,
|
||||||
|
}) => {
|
||||||
|
const [availableTables, setAvailableTables] = useState<Array<{ tableName: string; displayName: string }>>([]);
|
||||||
|
const [loadingTables, setLoadingTables] = useState(false);
|
||||||
|
const [tableComboboxOpen, setTableComboboxOpen] = useState(false);
|
||||||
|
const [availableColumns, setAvailableColumns] = useState<Array<{ columnName: string; label: string }>>([]);
|
||||||
|
const [loadingColumns, setLoadingColumns] = useState(false);
|
||||||
|
const [columnComboboxOpen, setColumnComboboxOpen] = useState(false);
|
||||||
|
|
||||||
|
const handleChange = (key: keyof ApprovalStepConfig, value: any) => {
|
||||||
|
onChange({ [key]: value });
|
||||||
|
};
|
||||||
|
|
||||||
|
const targetTableName = config.targetTable || screenTableName;
|
||||||
|
|
||||||
|
// 테이블 목록 가져오기 - tableTypeApi 사용 (다른 ConfigPanel과 동일)
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchTables = async () => {
|
||||||
|
setLoadingTables(true);
|
||||||
|
try {
|
||||||
|
const response = await tableTypeApi.getTables();
|
||||||
|
setAvailableTables(
|
||||||
|
response.map((table: any) => ({
|
||||||
|
tableName: table.tableName,
|
||||||
|
displayName: table.displayName || table.tableName,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("테이블 목록 가져오기 실패:", error);
|
||||||
|
} finally {
|
||||||
|
setLoadingTables(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fetchTables();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 선택된 테이블의 컬럼 로드 - tableManagementApi 사용 (다른 ConfigPanel과 동일)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!targetTableName) {
|
||||||
|
setAvailableColumns([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchColumns = async () => {
|
||||||
|
setLoadingColumns(true);
|
||||||
|
try {
|
||||||
|
const result = await tableManagementApi.getColumnList(targetTableName);
|
||||||
|
if (result.success && result.data) {
|
||||||
|
const columns = Array.isArray(result.data) ? result.data : result.data.columns;
|
||||||
|
if (columns && Array.isArray(columns)) {
|
||||||
|
setAvailableColumns(
|
||||||
|
columns.map((col: any) => ({
|
||||||
|
columnName: col.columnName || col.column_name || col.name,
|
||||||
|
label: col.displayName || col.columnLabel || col.column_label || col.columnName || col.column_name || col.name,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("컬럼 목록 가져오기 실패:", error);
|
||||||
|
setAvailableColumns([]);
|
||||||
|
} finally {
|
||||||
|
setLoadingColumns(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fetchColumns();
|
||||||
|
}, [targetTableName]);
|
||||||
|
|
||||||
|
const handleTableChange = (newTableName: string) => {
|
||||||
|
if (newTableName === targetTableName) return;
|
||||||
|
handleChange("targetTable", newTableName);
|
||||||
|
handleChange("targetRecordIdField", "");
|
||||||
|
setTableComboboxOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="text-sm font-medium">결재 단계 설정</div>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* 대상 테이블 선택 - TableListConfigPanel과 동일한 Combobox */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold">데이터 소스</h3>
|
||||||
|
<p className="text-muted-foreground text-[10px]">
|
||||||
|
결재 상태를 조회할 대상 테이블을 선택하세요.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<hr className="border-border" />
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs">대상 테이블</Label>
|
||||||
|
<Popover open={tableComboboxOpen} onOpenChange={setTableComboboxOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
role="combobox"
|
||||||
|
aria-expanded={tableComboboxOpen}
|
||||||
|
className="h-8 w-full justify-between text-xs"
|
||||||
|
disabled={loadingTables}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 truncate">
|
||||||
|
<Table2 className="h-3 w-3 shrink-0" />
|
||||||
|
<span className="truncate">
|
||||||
|
{loadingTables
|
||||||
|
? "테이블 로딩 중..."
|
||||||
|
: targetTableName
|
||||||
|
? availableTables.find((t) => t.tableName === targetTableName)?.displayName ||
|
||||||
|
targetTableName
|
||||||
|
: "테이블 선택"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent
|
||||||
|
className="p-0"
|
||||||
|
style={{ width: "var(--radix-popover-trigger-width)" }}
|
||||||
|
align="start"
|
||||||
|
>
|
||||||
|
<Command>
|
||||||
|
<CommandInput placeholder="테이블 검색..." className="text-xs" />
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty className="text-xs">테이블을 찾을 수 없습니다.</CommandEmpty>
|
||||||
|
<CommandGroup>
|
||||||
|
{availableTables.map((table) => (
|
||||||
|
<CommandItem
|
||||||
|
key={table.tableName}
|
||||||
|
value={`${table.tableName} ${table.displayName}`}
|
||||||
|
onSelect={() => handleTableChange(table.tableName)}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={cn(
|
||||||
|
"mr-2 h-3 w-3",
|
||||||
|
targetTableName === table.tableName ? "opacity-100" : "opacity-0"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span>{table.displayName}</span>
|
||||||
|
{table.displayName !== table.tableName && (
|
||||||
|
<span className="text-[10px] text-gray-400">{table.tableName}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
{screenTableName && targetTableName !== screenTableName && (
|
||||||
|
<div className="flex items-center justify-between rounded bg-amber-50 px-2 py-1">
|
||||||
|
<span className="text-[10px] text-amber-700">
|
||||||
|
화면 기본 테이블({screenTableName})과 다른 테이블을 사용 중
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-5 px-1.5 text-[10px] text-amber-700 hover:text-amber-900"
|
||||||
|
onClick={() => handleTableChange(screenTableName)}
|
||||||
|
>
|
||||||
|
기본으로
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 레코드 ID 필드 선택 - 동일한 Combobox 패턴 */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold">레코드 식별</h3>
|
||||||
|
<p className="text-muted-foreground text-[10px]">
|
||||||
|
결재 대상 레코드를 식별할 PK 컬럼을 선택하세요.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<hr className="border-border" />
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs">레코드 ID 필드명</Label>
|
||||||
|
{targetTableName ? (
|
||||||
|
<Popover open={columnComboboxOpen} onOpenChange={setColumnComboboxOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
role="combobox"
|
||||||
|
aria-expanded={columnComboboxOpen}
|
||||||
|
className="h-8 w-full justify-between text-xs"
|
||||||
|
disabled={loadingColumns}
|
||||||
|
>
|
||||||
|
<span className="truncate">
|
||||||
|
{loadingColumns
|
||||||
|
? "컬럼 로딩 중..."
|
||||||
|
: config.targetRecordIdField
|
||||||
|
? availableColumns.find((c) => c.columnName === config.targetRecordIdField)?.label ||
|
||||||
|
config.targetRecordIdField
|
||||||
|
: "컬럼 선택"}
|
||||||
|
</span>
|
||||||
|
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent
|
||||||
|
className="p-0"
|
||||||
|
style={{ width: "var(--radix-popover-trigger-width)" }}
|
||||||
|
align="start"
|
||||||
|
>
|
||||||
|
<Command>
|
||||||
|
<CommandInput placeholder="컬럼 검색..." className="text-xs" />
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty className="text-xs">컬럼을 찾을 수 없습니다.</CommandEmpty>
|
||||||
|
<CommandGroup>
|
||||||
|
{availableColumns.map((col) => (
|
||||||
|
<CommandItem
|
||||||
|
key={col.columnName}
|
||||||
|
value={`${col.columnName} ${col.label}`}
|
||||||
|
onSelect={() => {
|
||||||
|
handleChange("targetRecordIdField", col.columnName);
|
||||||
|
setColumnComboboxOpen(false);
|
||||||
|
}}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={cn(
|
||||||
|
"mr-2 h-3 w-3",
|
||||||
|
config.targetRecordIdField === col.columnName ? "opacity-100" : "opacity-0"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span>{col.label}</span>
|
||||||
|
{col.label !== col.columnName && (
|
||||||
|
<span className="text-[10px] text-gray-400">{col.columnName}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
) : (
|
||||||
|
<p className="text-muted-foreground text-[10px]">
|
||||||
|
대상 테이블을 먼저 선택하세요.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 표시 모드 */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold">표시 설정</h3>
|
||||||
|
<p className="text-muted-foreground text-[10px]">
|
||||||
|
결재 단계의 표시 방식을 설정합니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<hr className="border-border" />
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs">표시 모드</Label>
|
||||||
|
<Select
|
||||||
|
value={config.displayMode || "horizontal"}
|
||||||
|
onValueChange={(v) => handleChange("displayMode", v)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 text-xs">
|
||||||
|
<SelectValue placeholder="표시 모드 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="horizontal">가로형 스테퍼</SelectItem>
|
||||||
|
<SelectItem value="vertical">세로형 타임라인</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 옵션 체크박스들 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs font-medium">표시 옵션</Label>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Checkbox
|
||||||
|
id="showDept"
|
||||||
|
checked={config.showDept !== false}
|
||||||
|
onCheckedChange={(checked) => handleChange("showDept", !!checked)}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="showDept" className="text-xs font-normal">
|
||||||
|
부서/직급 표시
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Checkbox
|
||||||
|
id="showComment"
|
||||||
|
checked={config.showComment !== false}
|
||||||
|
onCheckedChange={(checked) => handleChange("showComment", !!checked)}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="showComment" className="text-xs font-normal">
|
||||||
|
결재 코멘트 표시
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Checkbox
|
||||||
|
id="showTimestamp"
|
||||||
|
checked={config.showTimestamp !== false}
|
||||||
|
onCheckedChange={(checked) => handleChange("showTimestamp", !!checked)}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="showTimestamp" className="text-xs font-normal">
|
||||||
|
처리 시각 표시
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Checkbox
|
||||||
|
id="compact"
|
||||||
|
checked={config.compact || false}
|
||||||
|
onCheckedChange={(checked) => handleChange("compact", !!checked)}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="compact" className="text-xs font-normal">
|
||||||
|
콤팩트 모드 (작게 표시)
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer";
|
||||||
|
import { V2ApprovalStepDefinition } from "./index";
|
||||||
|
import { ApprovalStepComponent } from "./ApprovalStepComponent";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ApprovalStep 렌더러
|
||||||
|
* 자동 등록 시스템을 사용하여 컴포넌트를 레지스트리에 등록
|
||||||
|
*/
|
||||||
|
export class ApprovalStepRenderer extends AutoRegisteringComponentRenderer {
|
||||||
|
static componentDefinition = V2ApprovalStepDefinition;
|
||||||
|
|
||||||
|
render(): React.ReactElement {
|
||||||
|
return <ApprovalStepComponent {...this.props} renderer={this} />;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ApprovalStepRenderer.registerSelf();
|
||||||
|
|
@ -0,0 +1,42 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { createComponentDefinition } from "../../utils/createComponentDefinition";
|
||||||
|
import { ComponentCategory } from "@/types/component";
|
||||||
|
import type { WebType } from "@/types/screen";
|
||||||
|
import { ApprovalStepWrapper } from "./ApprovalStepComponent";
|
||||||
|
import { ApprovalStepConfigPanel } from "./ApprovalStepConfigPanel";
|
||||||
|
import { ApprovalStepConfig } from "./types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ApprovalStep 컴포넌트 정의
|
||||||
|
* 결재 단계를 시각적으로 표시하는 스테퍼 컴포넌트
|
||||||
|
*/
|
||||||
|
export const V2ApprovalStepDefinition = createComponentDefinition({
|
||||||
|
id: "v2-approval-step",
|
||||||
|
name: "결재 단계",
|
||||||
|
nameEng: "ApprovalStep Component",
|
||||||
|
description: "결재 요청의 각 단계별 상태를 스테퍼 형태로 시각화합니다",
|
||||||
|
category: ComponentCategory.DISPLAY,
|
||||||
|
webType: "text",
|
||||||
|
component: ApprovalStepWrapper,
|
||||||
|
defaultConfig: {
|
||||||
|
targetTable: "",
|
||||||
|
targetRecordIdField: "",
|
||||||
|
displayMode: "horizontal",
|
||||||
|
showComment: true,
|
||||||
|
showTimestamp: true,
|
||||||
|
showDept: true,
|
||||||
|
compact: false,
|
||||||
|
},
|
||||||
|
defaultSize: { width: 500, height: 100 },
|
||||||
|
configPanel: ApprovalStepConfigPanel,
|
||||||
|
icon: "GitBranchPlus",
|
||||||
|
tags: ["결재", "승인", "단계", "스테퍼", "워크플로우"],
|
||||||
|
version: "1.0.0",
|
||||||
|
author: "개발팀",
|
||||||
|
});
|
||||||
|
|
||||||
|
export type { ApprovalStepConfig } from "./types";
|
||||||
|
export { ApprovalStepComponent } from "./ApprovalStepComponent";
|
||||||
|
export { ApprovalStepRenderer } from "./ApprovalStepRenderer";
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ComponentConfig } from "@/types/component";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ApprovalStep 컴포넌트 설정 타입
|
||||||
|
* 결재 단계를 시각적으로 표시하는 컴포넌트
|
||||||
|
*/
|
||||||
|
export interface ApprovalStepConfig extends ComponentConfig {
|
||||||
|
targetTable?: string;
|
||||||
|
targetRecordIdField?: string;
|
||||||
|
displayMode?: "horizontal" | "vertical";
|
||||||
|
showComment?: boolean;
|
||||||
|
showTimestamp?: boolean;
|
||||||
|
showDept?: boolean;
|
||||||
|
compact?: boolean;
|
||||||
|
}
|
||||||
|
|
@ -56,7 +56,8 @@ export type ButtonActionType =
|
||||||
| "swap_fields" // 필드 값 교환 (출발지 ↔ 목적지)
|
| "swap_fields" // 필드 값 교환 (출발지 ↔ 목적지)
|
||||||
| "transferData" // 데이터 전달 (컴포넌트 간 or 화면 간)
|
| "transferData" // 데이터 전달 (컴포넌트 간 or 화면 간)
|
||||||
| "quickInsert" // 즉시 저장 (선택한 데이터를 특정 테이블에 즉시 INSERT)
|
| "quickInsert" // 즉시 저장 (선택한 데이터를 특정 테이블에 즉시 INSERT)
|
||||||
| "event"; // 이벤트 버스로 이벤트 발송 (스케줄 생성 등)
|
| "event" // 이벤트 버스로 이벤트 발송 (스케줄 생성 등)
|
||||||
|
| "approval"; // 결재 요청
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 버튼 액션 설정
|
* 버튼 액션 설정
|
||||||
|
|
@ -7579,19 +7580,14 @@ export class ButtonActionExecutor {
|
||||||
const selectedRow = context.selectedRowsData?.[0] || context.formData || {};
|
const selectedRow = context.selectedRowsData?.[0] || context.formData || {};
|
||||||
const targetTable = (config as any).approvalTargetTable || context.tableName || "";
|
const targetTable = (config as any).approvalTargetTable || context.tableName || "";
|
||||||
const recordIdField = (config as any).approvalRecordIdField || "id";
|
const recordIdField = (config as any).approvalRecordIdField || "id";
|
||||||
const targetRecordId = selectedRow[recordIdField] || "";
|
const targetRecordId = selectedRow?.[recordIdField] || "";
|
||||||
|
|
||||||
if (!targetRecordId) {
|
|
||||||
toast.warning("결재 대상 레코드를 선택해주세요.");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
window.dispatchEvent(
|
window.dispatchEvent(
|
||||||
new CustomEvent("open-approval-modal", {
|
new CustomEvent("open-approval-modal", {
|
||||||
detail: {
|
detail: {
|
||||||
targetTable,
|
targetTable,
|
||||||
targetRecordId: String(targetRecordId),
|
targetRecordId: targetRecordId ? String(targetRecordId) : "",
|
||||||
targetRecordData: selectedRow,
|
targetRecordData: Object.keys(selectedRow).length > 0 ? selectedRow : undefined,
|
||||||
definitionId: (config as any).approvalDefinitionId || undefined,
|
definitionId: (config as any).approvalDefinitionId || undefined,
|
||||||
screenId: context.screenId ? Number(context.screenId) : undefined,
|
screenId: context.screenId ? Number(context.screenId) : undefined,
|
||||||
buttonComponentId: context.formData?.buttonId,
|
buttonComponentId: context.formData?.buttonId,
|
||||||
|
|
|
||||||
|
|
@ -979,17 +979,17 @@ export class ImprovedButtonActionExecutor {
|
||||||
// 결재 요청 모달 열기
|
// 결재 요청 모달 열기
|
||||||
if (buttonConfig.actionType === "approval") {
|
if (buttonConfig.actionType === "approval") {
|
||||||
const actionConfig = (buttonConfig as any).componentConfig?.action || buttonConfig;
|
const actionConfig = (buttonConfig as any).componentConfig?.action || buttonConfig;
|
||||||
const selectedRow = context.selectedRows?.[0] || context.formData || formData;
|
const selectedRow = context.selectedRows?.[0] || context.formData || formData || {};
|
||||||
const targetTable = actionConfig.approvalTargetTable || "";
|
const targetTable = actionConfig.approvalTargetTable || "";
|
||||||
const recordIdField = actionConfig.approvalRecordIdField || "id";
|
const recordIdField = actionConfig.approvalRecordIdField || "id";
|
||||||
const targetRecordId = selectedRow[recordIdField] || "";
|
const targetRecordId = selectedRow?.[recordIdField] || "";
|
||||||
|
|
||||||
window.dispatchEvent(
|
window.dispatchEvent(
|
||||||
new CustomEvent("open-approval-modal", {
|
new CustomEvent("open-approval-modal", {
|
||||||
detail: {
|
detail: {
|
||||||
targetTable,
|
targetTable,
|
||||||
targetRecordId: String(targetRecordId),
|
targetRecordId: targetRecordId ? String(targetRecordId) : "",
|
||||||
targetRecordData: selectedRow,
|
targetRecordData: Object.keys(selectedRow).length > 0 ? selectedRow : undefined,
|
||||||
definitionId: actionConfig.approvalDefinitionId || undefined,
|
definitionId: actionConfig.approvalDefinitionId || undefined,
|
||||||
screenId: context.screenId ? Number(context.screenId) : undefined,
|
screenId: context.screenId ? Number(context.screenId) : undefined,
|
||||||
buttonComponentId: context.buttonId,
|
buttonComponentId: context.buttonId,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,171 @@
|
||||||
|
/**
|
||||||
|
* 결재함 플로우 E2E 테스트 스크립트
|
||||||
|
* 실행: npx tsx scripts/approval-flow-test.ts
|
||||||
|
*/
|
||||||
|
import { chromium } from "playwright";
|
||||||
|
|
||||||
|
const BASE_URL = "http://localhost:9771";
|
||||||
|
const LOGIN_ID = "wace";
|
||||||
|
const LOGIN_PW = "1234";
|
||||||
|
const FALLBACK_PW = "qlalfqjsgh11"; // 마스터 패스워드 (1234 실패 시)
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const results: string[] = [];
|
||||||
|
const consoleErrors: string[] = [];
|
||||||
|
|
||||||
|
const browser = await chromium.launch({ headless: true });
|
||||||
|
const context = await browser.newContext({
|
||||||
|
viewport: { width: 1280, height: 800 }, // 데스크톱 뷰 (사이드바 표시)
|
||||||
|
});
|
||||||
|
const page = await context.newPage();
|
||||||
|
|
||||||
|
// 콘솔 에러 수집
|
||||||
|
page.on("console", (msg) => {
|
||||||
|
const type = msg.type();
|
||||||
|
if (type === "error") {
|
||||||
|
const text = msg.text();
|
||||||
|
consoleErrors.push(text);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. http://localhost:9771 이동
|
||||||
|
results.push("=== 1. http://localhost:9771 이동 ===");
|
||||||
|
await page.goto(BASE_URL, { waitUntil: "networkidle", timeout: 15000 });
|
||||||
|
results.push("OK: 페이지 로드 완료");
|
||||||
|
|
||||||
|
// 2. 로그인 여부 확인
|
||||||
|
results.push("\n=== 2. 로그인 상태 확인 ===");
|
||||||
|
const isLoginPage = await page.locator('#userId, input[name="userId"]').count() > 0;
|
||||||
|
if (isLoginPage) {
|
||||||
|
results.push("로그인 페이지 감지됨. 로그인 시도...");
|
||||||
|
await page.fill('#userId', LOGIN_ID);
|
||||||
|
await page.fill('#password', LOGIN_PW);
|
||||||
|
await page.click('button[type="submit"]');
|
||||||
|
await page.waitForTimeout(4000);
|
||||||
|
|
||||||
|
// 여전히 로그인 페이지면 마스터 패스워드로 재시도
|
||||||
|
const stillLoginPage = await page.locator('#userId').count() > 0;
|
||||||
|
if (stillLoginPage) {
|
||||||
|
results.push("1234 로그인 실패. 마스터 패스워드로 재시도...");
|
||||||
|
await page.fill('#userId', LOGIN_ID);
|
||||||
|
await page.fill('#password', FALLBACK_PW);
|
||||||
|
await page.click('button[type="submit"]');
|
||||||
|
await page.waitForTimeout(4000);
|
||||||
|
}
|
||||||
|
results.push("로그인 폼 제출 완료");
|
||||||
|
} else {
|
||||||
|
results.push("이미 로그인된 상태로 판단 (로그인 폼 없음)");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 사용자 프로필 아바타 클릭 (사이드바 하단)
|
||||||
|
results.push("\n=== 3. 사용자 프로필 아바타 클릭 ===");
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
|
||||||
|
// 사이드바 하단 사용자 프로필 버튼 (border-t border-slate-200 내부의 button)
|
||||||
|
const sidebarAvatarBtn = page.locator('aside div.border-t.border-slate-200 button').first();
|
||||||
|
let avatarClicked = false;
|
||||||
|
if ((await sidebarAvatarBtn.count()) > 0) {
|
||||||
|
try {
|
||||||
|
// force: true - Next.js dev overlay가 클릭을 가로채는 경우 우회
|
||||||
|
await sidebarAvatarBtn.click({ timeout: 5000, force: true });
|
||||||
|
avatarClicked = true;
|
||||||
|
results.push("OK: 사이드바 하단 아바타 클릭 완료");
|
||||||
|
await page.waitForTimeout(500); // 드롭다운 열림 대기
|
||||||
|
} catch (e) {
|
||||||
|
results.push(`WARN: 사이드바 아바타 클릭 실패 - ${(e as Error).message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!avatarClicked) {
|
||||||
|
// 모바일 헤더 아바타 또는 fallback
|
||||||
|
const headerAvatar = page.locator('header button:has(div.rounded-full)').first();
|
||||||
|
if ((await headerAvatar.count()) > 0) {
|
||||||
|
await headerAvatar.click({ force: true });
|
||||||
|
avatarClicked = true;
|
||||||
|
results.push("OK: 헤더 아바타 클릭 (모바일 뷰?)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!avatarClicked) {
|
||||||
|
results.push("WARN: 아바타 클릭 실패. 직접 /admin/approvalBox로 이동하여 페이지 검증");
|
||||||
|
await page.goto(`${BASE_URL}/admin/approvalBox`, { waitUntil: "networkidle", timeout: 10000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
|
||||||
|
// 4. "결재함" 메뉴 확인 (드롭다운이 열린 경우)
|
||||||
|
results.push("\n=== 4. 결재함 메뉴 확인 ===");
|
||||||
|
const approvalMenuItem = page.locator('[role="menuitem"]:has-text("결재함"), [data-radix-collection-item]:has-text("결재함")').first();
|
||||||
|
const hasApprovalMenu = (await approvalMenuItem.count()) > 0;
|
||||||
|
if (hasApprovalMenu) {
|
||||||
|
results.push("OK: 결재함 메뉴가 보입니다.");
|
||||||
|
} else {
|
||||||
|
results.push("FAIL: 결재함 메뉴를 찾을 수 없습니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. 결재함 메뉴 클릭
|
||||||
|
results.push("\n=== 5. 결재함 메뉴 클릭 ===");
|
||||||
|
if (hasApprovalMenu) {
|
||||||
|
await approvalMenuItem.click({ force: true });
|
||||||
|
await page.waitForTimeout(3000);
|
||||||
|
results.push("OK: 결재함 메뉴 클릭 완료");
|
||||||
|
} else if (!avatarClicked) {
|
||||||
|
results.push("(직접 이동으로 스킵 - 이미 approvalBox 페이지)");
|
||||||
|
} else {
|
||||||
|
results.push("WARN: 드롭다운에서 결재함 메뉴 미발견. 직접 이동...");
|
||||||
|
await page.goto(`${BASE_URL}/admin/approvalBox`, { waitUntil: "networkidle", timeout: 10000 });
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. /admin/approvalBox 페이지 렌더링 확인
|
||||||
|
results.push("\n=== 6. /admin/approvalBox 페이지 확인 ===");
|
||||||
|
const currentUrl = page.url();
|
||||||
|
const isApprovalBoxPage = currentUrl.includes("/admin/approvalBox");
|
||||||
|
results.push(`현재 URL: ${currentUrl}`);
|
||||||
|
results.push(isApprovalBoxPage ? "OK: approvalBox 페이지에 있습니다." : "FAIL: approvalBox 페이지가 아닙니다.");
|
||||||
|
|
||||||
|
// 제목 "결재함" 확인
|
||||||
|
const titleEl = page.locator('h1:has-text("결재함")');
|
||||||
|
const hasTitle = (await titleEl.count()) > 0;
|
||||||
|
results.push(hasTitle ? "OK: 제목 '결재함' 확인됨" : "FAIL: 제목 '결재함' 없음");
|
||||||
|
|
||||||
|
// 탭 확인: 수신함, 상신함
|
||||||
|
const receivedTab = page.locator('button[role="tab"], [role="tab"]').filter({ hasText: "수신함" });
|
||||||
|
const sentTab = page.locator('button[role="tab"], [role="tab"]').filter({ hasText: "상신함" });
|
||||||
|
const hasReceivedTab = (await receivedTab.count()) > 0;
|
||||||
|
const hasSentTab = (await sentTab.count()) > 0;
|
||||||
|
results.push(hasReceivedTab ? "OK: '수신함' 탭 확인됨" : "FAIL: '수신함' 탭 없음");
|
||||||
|
results.push(hasSentTab ? "OK: '상신함' 탭 확인됨" : "FAIL: '상신함' 탭 없음");
|
||||||
|
|
||||||
|
// 7. 콘솔 에러 확인
|
||||||
|
results.push("\n=== 7. 콘솔 에러 확인 ===");
|
||||||
|
if (consoleErrors.length === 0) {
|
||||||
|
results.push("OK: 콘솔 에러 없음");
|
||||||
|
} else {
|
||||||
|
results.push(`WARN: 콘솔 에러 ${consoleErrors.length}건 발견:`);
|
||||||
|
consoleErrors.slice(0, 10).forEach((err, i) => {
|
||||||
|
results.push(` [${i + 1}] ${err.substring(0, 200)}${err.length > 200 ? "..." : ""}`);
|
||||||
|
});
|
||||||
|
if (consoleErrors.length > 10) {
|
||||||
|
results.push(` ... 외 ${consoleErrors.length - 10}건`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 스크린샷 저장 (프로젝트 내)
|
||||||
|
await page.screenshot({ path: "approval-box-result.png" }).catch(() => {});
|
||||||
|
} catch (err: any) {
|
||||||
|
results.push(`\nERROR: ${err.message}`);
|
||||||
|
} finally {
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 결과 출력
|
||||||
|
console.log("\n" + "=".repeat(60));
|
||||||
|
console.log("결재함 플로우 테스트 결과");
|
||||||
|
console.log("=".repeat(60));
|
||||||
|
results.forEach((r) => console.log(r));
|
||||||
|
console.log("\n" + "=".repeat(60));
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
Loading…
Reference in New Issue