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: "인증 정보가 없습니다." });
|
||||
}
|
||||
|
||||
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 params: any[] = [companyCode];
|
||||
|
|
@ -495,6 +495,10 @@ export class ApprovalRequestController {
|
|||
conditions.push(`r.target_table = $${idx++}`);
|
||||
params.push(target_table);
|
||||
}
|
||||
if (target_record_id) {
|
||||
conditions.push(`r.target_record_id = $${idx++}`);
|
||||
params.push(target_record_id);
|
||||
}
|
||||
if (requester_id) {
|
||||
conditions.push(`r.requester_id = $${idx++}`);
|
||||
params.push(requester_id);
|
||||
|
|
@ -595,10 +599,11 @@ export class ApprovalRequestController {
|
|||
title, description, definition_id, target_table, target_record_id,
|
||||
target_record_data, screen_id, button_component_id,
|
||||
approvers, // [{ approver_id, approver_name, approver_position, approver_dept, approver_label }]
|
||||
approval_mode, // "sequential" | "parallel"
|
||||
} = req.body;
|
||||
|
||||
if (!title || !target_table || !target_record_id) {
|
||||
return res.status(400).json({ success: false, message: "제목, 대상 테이블, 대상 레코드 ID는 필수입니다." });
|
||||
if (!title || !target_table) {
|
||||
return res.status(400).json({ success: false, message: "제목과 대상 테이블은 필수입니다." });
|
||||
}
|
||||
|
||||
if (!Array.isArray(approvers) || approvers.length === 0) {
|
||||
|
|
@ -609,6 +614,15 @@ export class ApprovalRequestController {
|
|||
const userName = req.user?.userName || "";
|
||||
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;
|
||||
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)
|
||||
RETURNING *`,
|
||||
[
|
||||
title, description, definition_id, target_table, target_record_id,
|
||||
target_record_data ? JSON.stringify(target_record_data) : null,
|
||||
approvers.length,
|
||||
title, description, definition_id, target_table, target_record_id || null,
|
||||
JSON.stringify(mergedRecordData),
|
||||
totalSteps,
|
||||
userId, userName, deptName,
|
||||
screen_id, button_component_id, companyCode,
|
||||
]
|
||||
);
|
||||
result = reqRows[0];
|
||||
|
||||
// 결재 라인 생성 (첫 번째 단계는 pending, 나머지는 waiting)
|
||||
// 결재 라인 생성
|
||||
// 동시결재: 모든 결재자 pending (step_order는 고유값) / 다단결재: 첫 번째만 pending
|
||||
for (let i = 0; i < approvers.length; i++) {
|
||||
const approver = approvers[i];
|
||||
const lineStatus = isParallel ? "pending" : (i === 0 ? "pending" : "waiting");
|
||||
|
||||
await client.query(
|
||||
`INSERT INTO approval_lines (
|
||||
request_id, step_order, approver_id, approver_name, approver_position,
|
||||
|
|
@ -645,8 +662,8 @@ export class ApprovalRequestController {
|
|||
approver.approver_name || null,
|
||||
approver.approver_position || null,
|
||||
approver.approver_dept || null,
|
||||
approver.approver_label || `${i + 1}차 결재`,
|
||||
i === 0 ? "pending" : "waiting",
|
||||
approver.approver_label || (isParallel ? "동시 결재" : `${i + 1}차 결재`),
|
||||
lineStatus,
|
||||
companyCode,
|
||||
]
|
||||
);
|
||||
|
|
@ -777,12 +794,41 @@ export class ApprovalLineController {
|
|||
WHERE request_id = $3`,
|
||||
[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 {
|
||||
// 승인: 다음 단계 활성화 또는 최종 완료
|
||||
// 승인: 동시결재 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;
|
||||
|
||||
if (nextStep <= request.total_steps) {
|
||||
// 다음 결재자를 pending으로 변경
|
||||
await client.query(
|
||||
`UPDATE approval_lines SET status = 'pending'
|
||||
WHERE request_id = $1 AND step_order = $2 AND company_code = $3`,
|
||||
|
|
@ -793,7 +839,6 @@ export class ApprovalLineController {
|
|||
[nextStep, line.request_id]
|
||||
);
|
||||
} else {
|
||||
// 마지막 단계 승인 → 최종 완료
|
||||
await client.query(
|
||||
`UPDATE approval_requests SET status = 'approved', final_approver_id = $1, final_comment = $2,
|
||||
completed_at = NOW(), updated_at = NOW()
|
||||
|
|
@ -802,6 +847,7 @@ export class ApprovalLineController {
|
|||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
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 {
|
||||
user_id: string;
|
||||
user_name: string;
|
||||
userId: string;
|
||||
userName: string;
|
||||
positionName?: string;
|
||||
deptName?: string;
|
||||
deptCode?: string;
|
||||
email?: string;
|
||||
user_id?: string;
|
||||
user_name?: string;
|
||||
position_name?: string;
|
||||
dept_name?: string;
|
||||
dept_code?: string;
|
||||
email?: string;
|
||||
}
|
||||
|
||||
function genId(): string {
|
||||
|
|
@ -98,10 +102,17 @@ export const ApprovalRequestModal: React.FC<ApprovalRequestModalProps> = ({
|
|||
try {
|
||||
const res = await getUserList({ search: query.trim(), limit: 20 });
|
||||
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));
|
||||
setSearchResults(users.filter((u) => !existingIds.has(u.user_id)));
|
||||
setSearchResults(users.filter((u) => u.userId && !existingIds.has(u.userId)));
|
||||
} catch {
|
||||
setSearchResults([]);
|
||||
} finally {
|
||||
|
|
@ -128,10 +139,10 @@ export const ApprovalRequestModal: React.FC<ApprovalRequestModalProps> = ({
|
|||
...prev,
|
||||
{
|
||||
id: genId(),
|
||||
user_id: user.user_id,
|
||||
user_name: user.user_name,
|
||||
position_name: user.position_name || "",
|
||||
dept_name: user.dept_name || "",
|
||||
user_id: user.userId,
|
||||
user_name: user.userName,
|
||||
position_name: user.positionName || "",
|
||||
dept_name: user.deptName || "",
|
||||
},
|
||||
]);
|
||||
setSearchQuery("");
|
||||
|
|
@ -162,8 +173,8 @@ export const ApprovalRequestModal: React.FC<ApprovalRequestModalProps> = ({
|
|||
setError("결재자를 1명 이상 추가해주세요.");
|
||||
return;
|
||||
}
|
||||
if (!eventDetail?.targetTable || !eventDetail?.targetRecordId) {
|
||||
setError("결재 대상 정보가 없습니다. 레코드를 선택 후 다시 시도해주세요.");
|
||||
if (!eventDetail?.targetTable) {
|
||||
setError("결재 대상 테이블 정보가 없습니다. 버튼 설정을 확인해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -174,11 +185,9 @@ export const ApprovalRequestModal: React.FC<ApprovalRequestModalProps> = ({
|
|||
title: title.trim(),
|
||||
description: description.trim() || undefined,
|
||||
target_table: eventDetail.targetTable,
|
||||
target_record_id: eventDetail.targetRecordId,
|
||||
target_record_data: {
|
||||
...eventDetail.targetRecordData,
|
||||
target_record_id: eventDetail.targetRecordId || undefined,
|
||||
target_record_data: eventDetail.targetRecordData,
|
||||
approval_mode: approvalMode,
|
||||
},
|
||||
screen_id: eventDetail.screenId,
|
||||
button_component_id: eventDetail.buttonComponentId,
|
||||
approvers: approvers.map((a, idx) => ({
|
||||
|
|
@ -321,7 +330,7 @@ export const ApprovalRequestModal: React.FC<ApprovalRequestModalProps> = ({
|
|||
<div className="max-h-48 overflow-y-auto">
|
||||
{searchResults.map((user) => (
|
||||
<button
|
||||
key={user.user_id}
|
||||
key={user.userId}
|
||||
type="button"
|
||||
onClick={() => addApprover(user)}
|
||||
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 className="min-w-0 flex-1">
|
||||
<p className="truncate text-xs font-medium sm:text-sm">
|
||||
{user.user_name}
|
||||
{user.userName}
|
||||
<span className="text-muted-foreground ml-1 text-[10px]">
|
||||
({user.user_id})
|
||||
({user.userId})
|
||||
</span>
|
||||
</p>
|
||||
<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>
|
||||
</div>
|
||||
<Plus className="text-muted-foreground h-4 w-4 shrink-0" />
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import {
|
|||
LogOut,
|
||||
User,
|
||||
Building2,
|
||||
FileCheck,
|
||||
} from "lucide-react";
|
||||
import { useMenu } from "@/contexts/MenuContext";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
|
|
@ -524,6 +525,11 @@ function AppLayoutInner({ children }: AppLayoutProps) {
|
|||
<User className="mr-2 h-4 w-4" />
|
||||
<span>프로필</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => router.push("/admin/approvalBox")}>
|
||||
<FileCheck className="mr-2 h-4 w-4" />
|
||||
<span>결재함</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={handleLogout}>
|
||||
<LogOut className="mr-2 h-4 w-4" />
|
||||
<span>로그아웃</span>
|
||||
|
|
@ -692,6 +698,11 @@ function AppLayoutInner({ children }: AppLayoutProps) {
|
|||
<User className="mr-2 h-4 w-4" />
|
||||
<span>프로필</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => router.push("/admin/approvalBox")}>
|
||||
<FileCheck className="mr-2 h-4 w-4" />
|
||||
<span>결재함</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={handleLogout}>
|
||||
<LogOut className="mr-2 h-4 w-4" />
|
||||
<span>로그아웃</span>
|
||||
|
|
|
|||
|
|
@ -8,7 +8,8 @@ import {
|
|||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} 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 {
|
||||
user: any;
|
||||
|
|
@ -20,6 +21,8 @@ interface UserDropdownProps {
|
|||
* 사용자 드롭다운 메뉴 컴포넌트
|
||||
*/
|
||||
export function UserDropdown({ user, onProfileClick, onLogout }: UserDropdownProps) {
|
||||
const router = useRouter();
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
return (
|
||||
|
|
@ -79,6 +82,11 @@ export function UserDropdown({ user, onProfileClick, onLogout }: UserDropdownPro
|
|||
<User className="mr-2 h-4 w-4" />
|
||||
<span>프로필</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => router.push("/admin/approvalBox")}>
|
||||
<FileCheck className="mr-2 h-4 w-4" />
|
||||
<span>결재함</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={onLogout}>
|
||||
<LogOut className="mr-2 h-4 w-4" />
|
||||
<span>로그아웃</span>
|
||||
|
|
|
|||
|
|
@ -577,8 +577,10 @@ export const OptimizedButtonComponent: React.FC<OptimizedButtonProps> = ({
|
|||
const getActionDisplayName = (actionType: ButtonActionType): string => {
|
||||
const displayNames: Record<ButtonActionType, string> = {
|
||||
save: "저장",
|
||||
cancel: "취소",
|
||||
delete: "삭제",
|
||||
edit: "수정",
|
||||
copy: "복사",
|
||||
add: "추가",
|
||||
search: "검색",
|
||||
reset: "초기화",
|
||||
|
|
@ -589,6 +591,9 @@ export const OptimizedButtonComponent: React.FC<OptimizedButtonProps> = ({
|
|||
newWindow: "새 창",
|
||||
navigate: "페이지 이동",
|
||||
control: "제어",
|
||||
transferData: "데이터 전달",
|
||||
quickInsert: "즉시 저장",
|
||||
approval: "결재",
|
||||
};
|
||||
return displayNames[actionType] || actionType;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -152,10 +152,11 @@ export interface CreateApprovalRequestInput {
|
|||
description?: string;
|
||||
definition_id?: number;
|
||||
target_table: string;
|
||||
target_record_id: string;
|
||||
target_record_id?: string;
|
||||
target_record_data?: Record<string, any>;
|
||||
screen_id?: number;
|
||||
button_component_id?: string;
|
||||
approval_mode?: "sequential" | "parallel";
|
||||
approvers: {
|
||||
approver_id: string;
|
||||
approver_name?: string;
|
||||
|
|
@ -351,6 +352,7 @@ export async function deleteApprovalTemplate(id: number): Promise<ApiResponse<vo
|
|||
export async function getApprovalRequests(params?: {
|
||||
status?: string;
|
||||
target_table?: string;
|
||||
target_record_id?: string;
|
||||
requester_id?: string;
|
||||
my_approvals?: boolean;
|
||||
page?: number;
|
||||
|
|
@ -360,6 +362,7 @@ export async function getApprovalRequests(params?: {
|
|||
const qs = new URLSearchParams();
|
||||
if (params?.status) qs.append("status", params.status);
|
||||
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?.my_approvals !== undefined) qs.append("my_approvals", String(params.my_approvals));
|
||||
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-bom-tree/BomTreeRenderer"; // 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" // 필드 값 교환 (출발지 ↔ 목적지)
|
||||
| "transferData" // 데이터 전달 (컴포넌트 간 or 화면 간)
|
||||
| "quickInsert" // 즉시 저장 (선택한 데이터를 특정 테이블에 즉시 INSERT)
|
||||
| "event"; // 이벤트 버스로 이벤트 발송 (스케줄 생성 등)
|
||||
| "event" // 이벤트 버스로 이벤트 발송 (스케줄 생성 등)
|
||||
| "approval"; // 결재 요청
|
||||
|
||||
/**
|
||||
* 버튼 액션 설정
|
||||
|
|
@ -7579,19 +7580,14 @@ export class ButtonActionExecutor {
|
|||
const selectedRow = context.selectedRowsData?.[0] || context.formData || {};
|
||||
const targetTable = (config as any).approvalTargetTable || context.tableName || "";
|
||||
const recordIdField = (config as any).approvalRecordIdField || "id";
|
||||
const targetRecordId = selectedRow[recordIdField] || "";
|
||||
|
||||
if (!targetRecordId) {
|
||||
toast.warning("결재 대상 레코드를 선택해주세요.");
|
||||
return false;
|
||||
}
|
||||
const targetRecordId = selectedRow?.[recordIdField] || "";
|
||||
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("open-approval-modal", {
|
||||
detail: {
|
||||
targetTable,
|
||||
targetRecordId: String(targetRecordId),
|
||||
targetRecordData: selectedRow,
|
||||
targetRecordId: targetRecordId ? String(targetRecordId) : "",
|
||||
targetRecordData: Object.keys(selectedRow).length > 0 ? selectedRow : undefined,
|
||||
definitionId: (config as any).approvalDefinitionId || undefined,
|
||||
screenId: context.screenId ? Number(context.screenId) : undefined,
|
||||
buttonComponentId: context.formData?.buttonId,
|
||||
|
|
|
|||
|
|
@ -979,17 +979,17 @@ export class ImprovedButtonActionExecutor {
|
|||
// 결재 요청 모달 열기
|
||||
if (buttonConfig.actionType === "approval") {
|
||||
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 recordIdField = actionConfig.approvalRecordIdField || "id";
|
||||
const targetRecordId = selectedRow[recordIdField] || "";
|
||||
const targetRecordId = selectedRow?.[recordIdField] || "";
|
||||
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("open-approval-modal", {
|
||||
detail: {
|
||||
targetTable,
|
||||
targetRecordId: String(targetRecordId),
|
||||
targetRecordData: selectedRow,
|
||||
targetRecordId: targetRecordId ? String(targetRecordId) : "",
|
||||
targetRecordData: Object.keys(selectedRow).length > 0 ? selectedRow : undefined,
|
||||
definitionId: actionConfig.approvalDefinitionId || undefined,
|
||||
screenId: context.screenId ? Number(context.screenId) : undefined,
|
||||
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