feat: [approval] 전자결재 모듈 대폭 개선 + 회계 리팩토링
- 전자결재: 다양식 지원(11종), 완료함, 동적폼 렌더러, QA 보고서 - 회계: 계정과목 검색모달 리팩토링, 거래처/세금계산서 개선 - HR: 근태/휴가/직원 소소한 수정 - vehicle/quality/pricing 마이너 수정 - approval_backup_v1 백업 보관
This commit is contained in:
285
claudedocs/[QA-2026-03-16] approval-module-qa-report.md
Normal file
285
claudedocs/[QA-2026-03-16] approval-module-qa-report.md
Normal file
@@ -0,0 +1,285 @@
|
||||
# 결재 모듈 QA 검증 보고서 및 수정 계획서
|
||||
|
||||
**작성일**: 2026-03-16
|
||||
**검증 대상**: 결재관리 모듈 전체 (기안함, 결재함, 참조함, 완료함)
|
||||
**검증 범위**: 문서 분류/양식 선택, 등록/수정/삭제, 벨리데이션, 파일업로드
|
||||
**상태**: Phase 0~3 완료, 버그 수정 5건 완료 및 재검수 통과, Phase 2-B 미완료
|
||||
|
||||
---
|
||||
|
||||
## Phase 0: 문서 분류 / 양식 선택 검증 ✅ 완료
|
||||
|
||||
### 7개 카테고리, 17개 양식 전체 목록 확인
|
||||
|
||||
| 카테고리 | 양식 수 | 양식 목록 | 상태 |
|
||||
|---------|--------|----------|------|
|
||||
| 일반 (3) | 3 | 근태신청, 사유서, 품의서 | ✅ |
|
||||
| 경비 (2) | 2 | 지출결의서, 비용견적서 | ✅ |
|
||||
| 인사 (2) | 2 | 연차사용촉진 통지서 (1차), 연차사용촉진 통지서 (2차) | ✅ |
|
||||
| 총무 (2) | 2 | 공문서, 이사회의사록 | ✅ |
|
||||
| 재무 (1) | 1 | 견적서 | ✅ |
|
||||
| 총무/기타 (2) | 2 | 위임장, 사용인감계 | ✅ |
|
||||
| 증명서 (5) | 5 | 사직서, 위촉증명서, 경력증명서, 재직증명서, 사용인감계 | ✅ |
|
||||
|
||||
**결론**: 2단계 Select (카테고리 → 양식)이 정상 동작하며 모든 양식이 노출됨
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: 등록/수정/삭제 검증 ✅ 완료
|
||||
|
||||
### Phase 1-A: 일반 카테고리 ✅
|
||||
|
||||
#### 품의서 (proposal) — 전용 폼 ✅
|
||||
|
||||
| 테스트 항목 | 결과 | 비고 |
|
||||
|-----------|------|------|
|
||||
| 양식 선택 → 폼 렌더링 | ✅ | 제목, 거래처, 내용, 사유, 예상비용, 첨부파일 |
|
||||
| 미리보기 | ✅ | DocumentDetailModal에 정상 렌더링 |
|
||||
| 벨리데이션 (결재선 미지정) | ✅ | "결재선을 지정해주세요" toast |
|
||||
| 임시저장 | ✅ | AP-20260316-0001 발급 |
|
||||
| 상신 | ✅ | AP-20260316-0002 발급, 결재대기 전환 |
|
||||
| 수정 (기안함에서 클릭) | ✅ | 모든 필드 복원, 제목 변경 후 저장 성공 |
|
||||
| 삭제 | ✅ | 확인 다이얼로그 후 삭제 성공 |
|
||||
|
||||
#### 근태신청 (attendance_request) — 동적 폼 ✅
|
||||
|
||||
| 테스트 항목 | 결과 | 비고 |
|
||||
|-----------|------|------|
|
||||
| 양식 선택 → 폼 렌더링 | ✅ | DynamicFormRenderer 5필드 정상 |
|
||||
| 미리보기 | ✅ | 동적 폼 미리보기 정상 |
|
||||
| 임시저장 | ✅ | 부분 입력 시 성공 (빈 폼은 실패 — BUG #13) |
|
||||
| 상신 | ✅ | 부분 입력으로도 상신 성공 |
|
||||
|
||||
#### 사유서 (reason_report) — 동적 폼 ✅
|
||||
|
||||
| 테스트 항목 | 결과 | 비고 |
|
||||
|-----------|------|------|
|
||||
| 양식 선택 → 폼 렌더링 | ✅ | DynamicFormRenderer 정상 |
|
||||
| 미리보기 | ✅ | 정상 |
|
||||
|
||||
### Phase 1-B: 경비 카테고리 ✅
|
||||
|
||||
#### 지출결의서 (expenseReport) — 전용 폼 ✅
|
||||
|
||||
| 테스트 항목 | 결과 | 비고 |
|
||||
|-----------|------|------|
|
||||
| 양식 선택 → 폼 렌더링 | ✅ | 항목 추가/삭제 테이블, 카드 정보 |
|
||||
| 미리보기 | ✅ | 정상 |
|
||||
| 임시저장 → 수정 → 상신 | ✅ | 전체 CRUD 정상 |
|
||||
|
||||
#### 비용견적서 (expenseEstimate) — 전용 폼 ✅
|
||||
|
||||
| 테스트 항목 | 결과 | 비고 |
|
||||
|-----------|------|------|
|
||||
| 양식 선택 → 폼 렌더링 | ✅ | 항목 테이블, 지출합계/계좌잔액/최종차액 자동계산 |
|
||||
| 미리보기 | ✅ | 정상 |
|
||||
| 임시저장 → 수정 → 상신 | ✅ | 전체 CRUD 정상 |
|
||||
|
||||
### Phase 1-C: 나머지 카테고리 ✅
|
||||
|
||||
| 카테고리 | 양식 | 렌더링 | 미리보기 | 비고 |
|
||||
|---------|------|--------|---------|------|
|
||||
| 인사 | 연차촉진 1차 | ✅ | ✅ | 전체 CRUD 테스트 완료 |
|
||||
| 인사 | 연차촉진 2차 | ✅ | ✅ | |
|
||||
| 총무 | 공문서 | ✅ | ✅ | |
|
||||
| 재무 | 견적서 | ✅ | ✅ | |
|
||||
| 총무/기타 | 이사회의사록 | ✅ | ✅ | |
|
||||
| 총무/기타 | 위임장 | ✅ | ✅ | 경미 a11y 이슈 (BUG #12) |
|
||||
| 증명서 | 사용인감계 | ✅ | ✅ | 경미 a11y 이슈 (BUG #12) |
|
||||
| 증명서 | 사직서 | ✅ | ✅ | |
|
||||
| 증명서 | 위촉증명서 | ✅ | ✅ | |
|
||||
| 증명서 | 경력증명서 | ✅ | ✅ | |
|
||||
| 증명서 | 재직증명서 | ✅ | ✅ | |
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: 벨리데이션 체크 및 파일업로드 ✅ 완료
|
||||
|
||||
### 벨리데이션 테스트 결과
|
||||
|
||||
| 테스트 시나리오 | 결과 | 동작 |
|
||||
|--------------|------|------|
|
||||
| 결재자 미지정 → 상신 | ✅ | "결재선을 지정해주세요." toast (프론트엔드) |
|
||||
| 결재자 미지정 + 빈 폼 → 상신 | ✅ | 결재선 검증이 먼저 작동 |
|
||||
| 결재자 지정 + 빈 폼 → 상신 | ✅ | "내용은(는) 필수 항목입니다." toast (백엔드 API) |
|
||||
| 빈 폼 → 임시저장 | ❌ BUG #13 | 백엔드가 임시저장에도 content 필수 검증 적용 |
|
||||
| 부분 입력 → 임시저장 | ✅ | AP-20260316-0009 발급, 성공 |
|
||||
| 부분 입력 → 상신 | ⚠️ | 성공하지만 필드별 검증 부재 (BUG #14) |
|
||||
| 임시저장 반복 클릭 | ❌ BUG #11 | 매번 새 문서 생성 (중복) |
|
||||
|
||||
### 파일 업로드 테스트 결과
|
||||
|
||||
| 테스트 항목 | 결과 | 비고 |
|
||||
|-----------|------|------|
|
||||
| FileDropzone 렌더링 (품의서) | ✅ | "클릭하거나 파일을 드래그하세요" |
|
||||
| 이미지 파일 업로드 | ✅ | test-upload.png 정상 첨부 |
|
||||
| 첨부 파일 표시 | ✅ | "test-upload.png (새 파일) 73 B" |
|
||||
| 첨부 파일 삭제 | ✅ | 삭제 후 "첨부된 파일이 없습니다" 복원 |
|
||||
|
||||
---
|
||||
|
||||
## Phase 2-B: 대시보드 연동 검증 ⏳ 미완료
|
||||
|
||||
---
|
||||
|
||||
## 발견된 버그 목록 (전체)
|
||||
|
||||
### 🔴 CRITICAL
|
||||
|
||||
#### BUG #11: 임시저장 후 URL 미갱신 → 중복 문서 생성 + 삭제 불가
|
||||
|
||||
**증상**:
|
||||
1. 새 문서 작성(`?mode=new`)에서 임시저장 성공 후 URL이 `?mode=new`로 유지
|
||||
2. `isEditMode`가 false인 채로 유지됨
|
||||
3. 임시저장을 다시 클릭하면 `createApproval()` 재호출 → **매번 새 문서 생성** (AP-0009, AP-0010...)
|
||||
4. 삭제 버튼 클릭 시 `isEditMode`가 false이므로 API 호출 없이 `router.back()` 실행
|
||||
|
||||
**재현**: 새 문서 → 내용 입력 → 임시저장 → 임시저장 반복 → 기안함에서 중복 문서 확인
|
||||
|
||||
**파일**: `src/components/approval/DocumentCreate/index.tsx` lines 526-569
|
||||
|
||||
**수정 방안**:
|
||||
```typescript
|
||||
// handleSaveDraft 성공 후 URL 갱신 추가
|
||||
if (result.success && result.data?.id) {
|
||||
// URL을 edit 모드로 전환하여 이후 저장이 updateApproval을 호출하도록
|
||||
router.replace(`/approval/draft/new?id=${result.data.id}&mode=edit`, { scroll: false });
|
||||
// 또는 state로 관리
|
||||
setDocumentId(String(result.data.id));
|
||||
}
|
||||
```
|
||||
|
||||
**우선순위**: 🔴 CRITICAL — 데이터 중복 생성, 삭제 불가
|
||||
|
||||
---
|
||||
|
||||
### 🟡 MEDIUM
|
||||
|
||||
#### BUG #1: 상신 후 기안함 리다이렉트 시 목록 데이터 미로드
|
||||
|
||||
**증상**: 문서 상신 후 기안함으로 리다이렉트되지만 목록이 0건으로 표시. 새로고침 후 정상.
|
||||
|
||||
**파일**: `src/components/approval/DocumentCreate/index.tsx` (handleSubmit → router.push)
|
||||
|
||||
**수정 방안**: DraftBox의 데이터 로딩에 pathname 의존성 추가 또는 invalidate 후 딜레이
|
||||
|
||||
**우선순위**: 🟡 MEDIUM — 새로고침으로 해결 가능
|
||||
|
||||
---
|
||||
|
||||
#### BUG #13: 빈 폼 임시저장 시 백엔드 검증 에러
|
||||
|
||||
**증상**: 폼 필드를 하나도 입력하지 않은 상태에서 임시저장 클릭 시 "내용은(는) 필수 항목입니다." 에러
|
||||
|
||||
**원인**: 동적 폼의 `dynamicFormData`가 `{}`일 때 백엔드가 content 필수 검증 적용
|
||||
|
||||
**수정 방안**:
|
||||
- 프론트엔드: 빈 폼일 때 프론트엔드에서 "최소 1개 필드를 입력해주세요" 안내
|
||||
- 또는 백엔드: 임시저장(`is_submitted=false`) 시 content 필수 검증 제외
|
||||
|
||||
**우선순위**: 🟡 MEDIUM — 임시저장 UX 개선
|
||||
|
||||
---
|
||||
|
||||
#### BUG #14: 부분 입력 폼 상신 시 필드별 벨리데이션 미비
|
||||
|
||||
**증상**: 근태신청에서 신청자와 사유만 입력하고 신청유형/기간/일수 미입력 상태로 상신 성공
|
||||
|
||||
**원인**: 백엔드에서 `content` JSON 내부 필드별 필수값 검증을 하지 않음
|
||||
|
||||
**수정 방안**: 백엔드에서 양식별 required 필드 검증 추가 필요
|
||||
|
||||
**우선순위**: 🟡 MEDIUM — 불완전한 문서가 상신될 수 있음
|
||||
|
||||
---
|
||||
|
||||
### 🟢 LOW
|
||||
|
||||
#### BUG #12: 폼 헤더에 로딩 텍스트 a11y 이슈
|
||||
|
||||
**증상**: PowerOfAttorneyForm, SealUsageForm에서 `<h3>` 안에 로딩 `<span>` 포함
|
||||
- 로딩 중: "위임인 불러오는 중..." / "회사 정보 불러오는 중..."이 h3의 일부로 읽힘
|
||||
- 로딩 완료 후: 정상
|
||||
|
||||
**파일**:
|
||||
- `src/components/approval/DocumentCreate/PowerOfAttorneyForm.tsx` line 47
|
||||
- `src/components/approval/DocumentCreate/SealUsageForm.tsx` line 101
|
||||
|
||||
**수정 방안**: 로딩 텍스트를 `<h3>` 외부로 이동하거나 `aria-hidden` 추가
|
||||
|
||||
**우선순위**: 🟢 LOW — 일시적 상태, 기능 영향 없음
|
||||
|
||||
---
|
||||
|
||||
### ✅ 수정 완료 (이전 세션에서 해결)
|
||||
|
||||
| 버그 | 증상 | 수정 내용 |
|
||||
|------|------|----------|
|
||||
| BUG #2 (서버 hang) | startTransition + 서버 액션 deadlock | startTransition 제거, try/catch 패턴 적용 |
|
||||
| BUG #3 (Select 경고) | controlled/uncontrolled 전환 | value에 undefined 사용 + key prop |
|
||||
| BUG #7 (content empty) | 전용 폼 content가 빈 객체 | getDocumentContent()에 구조화된 데이터 추가 |
|
||||
| BUG #8 (명칭 불일치) | "지출 예상 내역서" → "비용견적서" | 11개 파일 명칭 통일 |
|
||||
| BUG #9 (key 중복 에러) | 저장된 항목 복원 시 id 누락 | transformApiToFormData()에 fallback ID 생성 |
|
||||
| BUG #10 (null Input) | Input value에 null 전달 | `?? ''` null guard 추가 |
|
||||
|
||||
---
|
||||
|
||||
## 수정 우선순위 정리
|
||||
|
||||
| 순위 | 버그 | 심각도 | 수정 난이도 | 파일 |
|
||||
|------|------|--------|-----------|------|
|
||||
| 1 | BUG #11 (중복 문서 생성) | 🔴 CRITICAL | 낮음 | `DocumentCreate/index.tsx` |
|
||||
| 2 | BUG #1 (리다이렉트 미로드) | 🟡 MEDIUM | 중간 | `DocumentCreate/index.tsx`, `DraftBox/index.tsx` |
|
||||
| 3 | BUG #13 (빈 폼 임시저장) | 🟡 MEDIUM | 낮음 | `DocumentCreate/index.tsx` (프론트) 또는 백엔드 |
|
||||
| 4 | BUG #14 (필드별 검증 미비) | 🟡 MEDIUM | 높음 | 백엔드 API |
|
||||
| 5 | BUG #12 (a11y 로딩 텍스트) | 🟢 LOW | 낮음 | `PowerOfAttorneyForm.tsx`, `SealUsageForm.tsx` |
|
||||
|
||||
---
|
||||
|
||||
## 테스트 데이터 정리 필요
|
||||
|
||||
QA 과정에서 생성된 테스트 문서:
|
||||
- AP-20260316-0009 (근태신청, 임시저장) — 중복 1
|
||||
- AP-20260316-0010 (근태신청, 임시저장) — 중복 2
|
||||
- AP-20260316-0011 (근태신청, 결재대기) — 부분 입력 상신
|
||||
- AP-20260316-0008 (연차촉진1차, 임시저장)
|
||||
|
||||
---
|
||||
|
||||
## 버그 수정 및 재검수 결과 ✅ 완료
|
||||
|
||||
### 수정 완료 (2026-03-16 14:00)
|
||||
|
||||
| BUG | 수정 내용 | 재검수 결과 | 검증 방법 |
|
||||
|-----|----------|-----------|----------|
|
||||
| **#11** (중복 생성) | `savedDocId` state 추가, 첫 저장 후 `isEditMode` 전환 | ✅ PASS | 1차 저장 `createApproval()` → 2차 저장 `updateApproval(55, ...)` 확인 |
|
||||
| **#1** (리다이렉트 미로드) | `router.back()` → `router.push('/approval/draft')` 변경 | ✅ PASS | 상신 후 기안함 9건 정상 로드, 토스트 표시 |
|
||||
| **#13** (빈 폼 임시저장) | 프론트엔드 사전 검증 추가 (동적/전용 폼 내용 체크) | ✅ PASS | "문서 내용을 최소 1개 이상 입력해주세요" 토스트 표시 |
|
||||
| **#14** (필수 필드 검증) | 프론트엔드 동적 폼 required 필드 검증 추가 | ✅ PASS | "필수 항목을 입력해주세요: 신청유형, 기간, 일수" 토스트 표시 |
|
||||
| **#12** (a11y 로딩) | `<h3>` 내부 로딩 span → `<div>` wrapper로 sibling 분리 | ✅ PASS | heading에 로딩 텍스트 미포함 확인 |
|
||||
|
||||
### 수정 파일 목록
|
||||
|
||||
| 파일 | 수정 내용 |
|
||||
|------|----------|
|
||||
| `src/components/approval/DocumentCreate/index.tsx` | BUG #11, #1, #13, #14 |
|
||||
| `src/components/approval/DocumentCreate/PowerOfAttorneyForm.tsx` | BUG #12 |
|
||||
| `src/components/approval/DocumentCreate/SealUsageForm.tsx` | BUG #12 |
|
||||
|
||||
---
|
||||
|
||||
## 전체 QA 진행 상태
|
||||
|
||||
| Phase | 상태 | 비고 |
|
||||
|-------|------|------|
|
||||
| Phase 0: 문서 분류/양식 선택 | ✅ 완료 | 7카테고리 17양식 전체 확인 |
|
||||
| Phase 1-A: 일반 카테고리 CRUD | ✅ 완료 | 품의서 전체 CRUD, 근태신청/사유서 렌더링+미리보기 |
|
||||
| Phase 1-B: 경비 카테고리 CRUD | ✅ 완료 | 지출결의서, 비용견적서 전체 CRUD |
|
||||
| Phase 1-C: 나머지 카테고리 | ✅ 완료 | 11개 양식 렌더링+미리보기 전체 통과 |
|
||||
| Phase 2: 벨리데이션/파일업로드 | ✅ 완료 | 7개 벨리데이션 시나리오, 파일 업로드/삭제 테스트 |
|
||||
| Phase 2-B: 대시보드 연동 | ⏳ 미완료 | |
|
||||
| Phase 3: 버그 정리/수정 계획 | ✅ 완료 | 본 문서 |
|
||||
| **버그 수정 + 재검수** | **✅ 완료** | **5건 수정, 5건 화면 재검수 통과** |
|
||||
|
||||
### QA 중 생성된 테스트 데이터
|
||||
- AP-20260316-0012 (근태신청, 결재대기) — BUG #11 재검수용
|
||||
@@ -0,0 +1,532 @@
|
||||
# 절곡품 모듈 구현 계획서
|
||||
|
||||
> 버디(경동기업 ERP) 절곡품 메뉴 분석 기반 SAM ERP 프론트엔드 구현 계획
|
||||
> 작성일: 2026-03-13 | 백엔드 API 작업 진행 중
|
||||
|
||||
---
|
||||
|
||||
## 1. 전체 개요
|
||||
|
||||
### 버디 절곡품 메뉴 구조 (6개 하위 페이지)
|
||||
|
||||
| # | 메뉴명 | 버디 URL | 데이터 건수 | 핵심 기능 |
|
||||
|---|--------|---------|-----------|---------|
|
||||
| 1 | 절곡 바라시 기초자료 | `/bending/list.php` | 265건 | 절곡 형상 마스터 + 그리기 도구 |
|
||||
| 2 | 재고생산/작업일지/중간검사성적서 | `/lot/list.php` | 201건 | LOT 관리 + 중간검사 PDF |
|
||||
| 3 | 가이드레일 | `/guiderail/list.php` | 20건 | 제품 설계 + 전개도 + 작업지시서 |
|
||||
| 4 | 케이스 (셔터박스) | `/shutterbox/list.php` | 30건 | 셔터박스 설계 + 전개도 + 작업지시서 |
|
||||
| 5 | 하단마감재 | `/bottombar/list.php` | 11건 | 마감재 설계 + 작업지시서 |
|
||||
| 6 | 절곡 재고현황 | `/lot/list_stock.php` | 집계 | 재고 요약 + 그룹별 현황 |
|
||||
|
||||
### SAM 라우트 구조 (신규)
|
||||
```
|
||||
src/app/[locale]/(protected)/production/bending/
|
||||
├── page.tsx ← 절곡 바라시 기초자료
|
||||
├── lot/
|
||||
│ └── page.tsx ← 재고생산/작업일지/중간검사성적서
|
||||
├── guiderail/
|
||||
│ └── page.tsx ← 가이드레일
|
||||
├── shutterbox/
|
||||
│ └── page.tsx ← 케이스(셔터박스)
|
||||
├── bottombar/
|
||||
│ └── page.tsx ← 하단마감재
|
||||
└── stock/
|
||||
└── page.tsx ← 절곡 재고현황
|
||||
```
|
||||
|
||||
### 컴포넌트 구조
|
||||
```
|
||||
src/components/production/bending/
|
||||
├── BendingMasterList.tsx ← 바라시 기초자료 목록
|
||||
├── BendingMasterForm.tsx ← 바라시 기초자료 등록/수정 (모달)
|
||||
├── BendingLotList.tsx ← LOT 목록
|
||||
├── BendingLotForm.tsx ← LOT 등록/수정 (모달)
|
||||
├── GuiderailList.tsx ← 가이드레일 목록
|
||||
├── GuiderailForm.tsx ← 가이드레일 등록/수정 (모달)
|
||||
├── ShutterboxList.tsx ← 셔터박스 목록
|
||||
├── ShutterboxForm.tsx ← 셔터박스 등록/수정 (모달)
|
||||
├── BottombarList.tsx ← 하단마감재 목록
|
||||
├── BottombarForm.tsx ← 하단마감재 등록/수정 (모달)
|
||||
├── BendingStockSummary.tsx ← 재고 요약 카드
|
||||
├── BendingStockTable.tsx ← 재고 현황 테이블
|
||||
├── WorkOrderViewer.tsx ← 작업지시서 보기 (공통)
|
||||
├── BlueprintImageManager.tsx ← 결합형태 이미지 관리 (공통)
|
||||
├── actions.ts ← Server Actions
|
||||
└── types.ts ← 타입 정의
|
||||
```
|
||||
|
||||
### 🔄 기존 재활용 컴포넌트 (신규 생성 불필요)
|
||||
|
||||
| 기존 컴포넌트 | 경로 | 재활용 용도 |
|
||||
|--------------|------|-----------|
|
||||
| **DrawingCanvas** | `src/components/items/DrawingCanvas.tsx` | 절곡 형상 그리기 도구 (그대로 사용) |
|
||||
| **BendingDiagramSection** | `src/components/items/ItemForm/BendingDiagramSection.tsx` | 전개도 파일 업로드/그리기/치수 테이블 UI 참조 |
|
||||
| **BendingPartForm** | `src/components/items/ItemForm/forms/parts/BendingPartForm.tsx` | 품목명/종류/재질/품목코드 로직 참조 |
|
||||
| **bending/types.ts** | `src/components/production/WorkOrders/documents/bending/types.ts` | 절곡 작업일지 타입 (GuideRailTypeData, ShutterBoxData 등) |
|
||||
|
||||
**DrawingCanvas 기능 현황** (품목관리에서 이미 사용 중):
|
||||
- Canvas 600×400 (반응형 스케일)
|
||||
- 도구: 펜, 직선, 사각형, 원, 텍스트, 지우개
|
||||
- 색상 팔레트 10색 + 선 두께 조절 (1~20px)
|
||||
- Undo, 전체 지우기, 히스토리 관리
|
||||
- 초기 이미지 로드 (기존 이미지 편집 가능)
|
||||
- PNG data URL로 저장 → onSave 콜백
|
||||
- Dialog 모달로 래핑됨 (open/onOpenChange props)
|
||||
|
||||
**BendingDiagramSection 기능 현황** (품목 등록 폼에서 사용 중):
|
||||
- 입력방식 선택: 파일 업로드 / 드로잉 (DrawingCanvas 연동)
|
||||
- FileDropzone으로 이미지/PDF 업로드 + 미리보기
|
||||
- 기존 파일 표시/다운로드/삭제 (수정 모드)
|
||||
- 전개도 상세 입력 테이블: 번호, 입력값, 연신율, 계산값, 음영, A각
|
||||
- 폭 합계 자동 계산 → setValue로 폼 필드 연동
|
||||
|
||||
---
|
||||
|
||||
## 2. 페이지별 상세 분석 및 구현 계획
|
||||
|
||||
### 2-1. 절곡 바라시 기초자료 (Bending Master)
|
||||
|
||||
#### 목록 페이지
|
||||
**필터 영역:**
|
||||
| 필터명 | 타입 | 옵션 | 기본값 |
|
||||
|--------|------|------|--------|
|
||||
| 대분류 | 라디오 버튼 (토글) | 전체 / 스크린 / 철재 | 전체 |
|
||||
| 인정/비인정 | 라디오 버튼 (토글) | 전체 / 인정 / 비인정 | 전체 |
|
||||
| 중분류 (절곡물 분류) | Select | 가이드레일, 케이스, 하단마감재, 마구리, L-BAR, 보강평철, 케이스용 연기차단재, 가이드레일용 연기차단재 | (중분류) |
|
||||
| 품명 | Select (동적) | 중분류 선택에 따라 변경 (~78개 옵션) | (품명) |
|
||||
| 키워드 검색 | 텍스트 입력 | - | - |
|
||||
|
||||
**테이블 컬럼:**
|
||||
| 순서 | 컬럼명 | 설명 | 정렬 |
|
||||
|------|--------|------|------|
|
||||
| 1 | NO | 순번 | ✅ |
|
||||
| 2 | 등록일 | yyyy-MM-dd | ✅ |
|
||||
| 3 | 대분류 | 스크린/철재 | ✅ |
|
||||
| 4 | 인정/비인정 | 인정/비인정 | ✅ |
|
||||
| 5 | 절곡물 분류 | 중분류 카테고리 | ✅ |
|
||||
| 6 | 품명 | 링크 (상세 팝업) | ✅ |
|
||||
| 7 | 규격(가로*세로) | 치수 | ✅ |
|
||||
| 8 | 이미지(형상) | 썸네일 이미지 | - |
|
||||
| 9 | 재질 | EGI 1.55T, SUS 1.2T 등 | ✅ |
|
||||
| 10 | 폭 합계 | 숫자 | ✅ |
|
||||
| 11 | 절곡회수 | 숫자 (색상 강조) | ✅ |
|
||||
| 12 | 역방향(음영) | 숫자 | ✅ |
|
||||
| 13 | A각 수 | 숫자 | ✅ |
|
||||
| 14 | 폭합 | 숫자 | ✅ |
|
||||
| 15 | 작성 | 작성자 | ✅ |
|
||||
| 16 | 검색어 | 품목 검색 키워드 | ✅ |
|
||||
| 17 | 비고 | 메모 | ✅ |
|
||||
|
||||
**액션 버튼:**
|
||||
- `신규` → 등록 폼 팝업
|
||||
- `절곡 모델설정 이동` → 별도 설정 페이지
|
||||
- `절곡 BOM 이동` → BOM 관리 페이지
|
||||
|
||||
**페이지네이션:** 50/100/200/500/1,000/2,000 entries
|
||||
|
||||
#### 등록/수정 폼 (모달/팝업)
|
||||
**폼 필드:**
|
||||
| 필드명 | 타입 | 필수 | 설명 |
|
||||
|--------|------|------|------|
|
||||
| 등록일 | DatePicker | ✅ | 기본값: 오늘 |
|
||||
| 형태 | 라디오 | ✅ | 스크린/철재 |
|
||||
| 인정/비인정 | 라디오 | ✅ | 인정/비인정 |
|
||||
| 절곡품 그룹 | Select | ✅ | 8개 옵션 |
|
||||
| 품명 | 텍스트 입력 | ✅ | |
|
||||
| 규격(가로*세로) | 텍스트 입력 | | |
|
||||
| 재질 | Select | | EGI 1.15T, EGI 1.55T, SUS 1.2T, SUS 1.5T |
|
||||
| 점검구 방향 | Select | | 양면/후면/밑면 점검구 (케이스 부품 전용) |
|
||||
| 케이스 너비 | 숫자 입력 | | 케이스 부품 전용 |
|
||||
| 케이스 높이 | 숫자 입력 | | 케이스 부품 전용 |
|
||||
| 전면부 밑 치수 | 숫자 입력 | | 케이스 부품 전용 |
|
||||
| 레일폭 | 숫자 입력 | | 케이스 부품 전용 |
|
||||
| 작성자 | 텍스트 (자동) | ✅ | 로그인 사용자 |
|
||||
| 품목 검색어 | 텍스트 입력 | | |
|
||||
| 비고 | 텍스트 입력 | | |
|
||||
|
||||
**절곡 형상 입력 테이블 (동적 행):**
|
||||
| 행 필드 | 설명 |
|
||||
|---------|------|
|
||||
| 번호 | 행 순번 (+/- 버튼) |
|
||||
| 입력 | 폭 치수 입력 (노란 배경) |
|
||||
| 연신율 | 연신율 값 |
|
||||
| 연신율계산 후 | 자동 계산 (읽기전용, 회색) |
|
||||
| 합계 | 누적 합계 (주황 배경) |
|
||||
| 음영 | 체크박스 (역방향 표시) |
|
||||
| A각 표시 | 체크박스 |
|
||||
|
||||
**하단 버튼:**
|
||||
- `모든칸 비우기` | `마지막 열추가` | `마지막 열삭제`
|
||||
|
||||
**우측 패널:**
|
||||
- `그리기` 버튼 → Canvas 기반 절곡 형상 드로잉
|
||||
- 이미지 붙여넣기 (Ctrl+V) 영역
|
||||
- 조회 모드에서는 그리기 비활성화
|
||||
|
||||
**상세 조회 모드 추가 버튼:**
|
||||
- `수정` | `복사` | `삭제` | `닫기`
|
||||
|
||||
#### 구현 포인트
|
||||
- **그리기 도구**: ✅ 기존 `DrawingCanvas` 재활용 (신규 구현 불필요)
|
||||
- **전개도 섹션**: ✅ `BendingDiagramSection` 패턴 참조 (파일업로드/그리기 전환, 치수 테이블)
|
||||
- **동적 행 관리**: 열 추가/삭제 + 자동 합계 계산 (BendingDiagramSection 로직 참조)
|
||||
- **조건부 필드**: 절곡품 그룹이 "케이스"일 때만 케이스 관련 필드 표시
|
||||
- **이미지 붙여넣기**: Clipboard API 활용
|
||||
|
||||
---
|
||||
|
||||
### 2-2. 재고생산/작업일지/중간검사성적서 (Bending LOT)
|
||||
|
||||
#### 목록 페이지
|
||||
**필터 영역:**
|
||||
| 필터명 | 타입 | 옵션 |
|
||||
|--------|------|------|
|
||||
| 품목명 | Select | 가이드레일(벽면형), 가이드레일(측면형), 연기차단재, 하단마감재(스크린), 하단마감재(철재), L-Bar, 케이스 |
|
||||
| 종류명 | Select | 화이바원단, SUS(마감), SUS(마감)2, EGI(마감), 스크린용, D형, C형, 본체, 본체(철재), 후면코너부, 린텔부, 점검구, 전면부 |
|
||||
| 모양&길이 | Select | W50×3000~4000, W80×3000~4000, 1219~4300 등 |
|
||||
| 키워드 검색 | 텍스트 입력 | |
|
||||
|
||||
**테이블 컬럼:**
|
||||
| 순서 | 컬럼명 | 설명 |
|
||||
|------|--------|------|
|
||||
| 1 | 번호 | 순번 |
|
||||
| 2 | 등록일 | yyyy-MM-dd |
|
||||
| 3 | 원자재 LOT | LOT 번호 (링크, 파란색) |
|
||||
| 4 | 원단 LOT | LOT 번호 (링크, 파란색) |
|
||||
| 5 | 생산 LOT | LOT 번호 (링크, 파란색) - 자동생성 규칙 있음 |
|
||||
| 6 | 중간검사성적서 | PDF 아이콘 (클릭→PDF 보기/다운로드) |
|
||||
| 7 | 품목명 | C(케이스), R(가이드레일) 등 약어+전체명 |
|
||||
| 8 | 종류 | F(전면부), L(린텔부) 등 약어+전체명 |
|
||||
| 9 | 모양&길이 | 40(4000) 형식 |
|
||||
| 10 | 수량 | 숫자 |
|
||||
| 11 | 작성 | 작성자 |
|
||||
| 12 | 비고 | 메모 |
|
||||
|
||||
**액션 버튼:**
|
||||
- `신규` → 등록 폼 팝업
|
||||
- `업로드` → 일괄 업로드 (엑셀 등)
|
||||
|
||||
**LOT 번호 자동생성 규칙:**
|
||||
- 형식: `{품목코드}{종류코드}{생산코드}{날짜}-{길이코드}`
|
||||
- 예: `CF4A15-40` = C(케이스) + F(전면부) + 4(연도끝자리) + A(상반기) + 15(날짜) + 40(4000mm)
|
||||
|
||||
#### 구현 포인트
|
||||
- **LOT 번호 자동생성**: 품목/종류/날짜 기반 규칙 엔진
|
||||
- **중간검사성적서 PDF**: PDF 뷰어 통합 (미리보기/다운로드)
|
||||
- **원자재/원단 LOT 연결**: LOT 선택 모달 (기존 수입검사 LOT 연계)
|
||||
- **업로드 기능**: 엑셀 일괄 등록
|
||||
|
||||
---
|
||||
|
||||
### 2-3. 가이드레일 (Guiderail)
|
||||
|
||||
#### 목록 페이지
|
||||
**필터 영역:**
|
||||
| 필터명 | 타입 | 옵션 |
|
||||
|--------|------|------|
|
||||
| 대분류 | 토글 | 전체/스크린/철재 |
|
||||
| 인정/비인정 | 토글 | 전체/인정/비인정 |
|
||||
| 모델 선택 | Select | (모델 선택) - 동적 |
|
||||
| 키워드 검색 | 텍스트 입력 | |
|
||||
|
||||
**테이블 컬럼:**
|
||||
| 순서 | 컬럼명 | 설명 |
|
||||
|------|--------|------|
|
||||
| 1 | 번호 | 순번 |
|
||||
| 2 | 등록일 | yyyy-MM-dd |
|
||||
| 3 | 대분류 | 스크린/철재 |
|
||||
| 4 | 인정/비인정 | |
|
||||
| 5 | 제품코드 | KSS02, KQTS01 등 |
|
||||
| 6 | 품목검색어 | |
|
||||
| 7 | 가로(너비) X 세로(폭) | 치수 (링크, 파란색) |
|
||||
| 8 | 형상 | 벽면형/측면형 (색상 구분) |
|
||||
| 9 | 마감 | SUS마감/EGI마감 등 |
|
||||
| 10 | 소요자재량 | SUS 1.2T(406) EGI 1.55T(398) 형식 |
|
||||
| 11 | 형태 | 조립도 이미지 (썸네일) |
|
||||
| 12 | 작업지시서 | `보기` 버튼 |
|
||||
| 13 | 작성 | 작성자 |
|
||||
| 14 | 비고 | 메모 |
|
||||
|
||||
**특수 액션 버튼:**
|
||||
- `신규` → 등록 폼 팝업
|
||||
- `결합형태 이미지 등록` → 조립 이미지 관리
|
||||
- `형태별 기본 전개도` → 기본 전개도 조회/관리
|
||||
|
||||
**모달/팝업:**
|
||||
1. **작업지시서 보기** → 인쇄 가능한 작업지시서 문서 (PDF 또는 프린트 뷰)
|
||||
2. **결합형태 이미지** → 이미지 업로드/관리
|
||||
3. **기본 전개도** → 형태별 전개도 이미지/설정
|
||||
|
||||
#### 구현 포인트
|
||||
- **제품코드 체계**: 모델별 자동 코드 생성
|
||||
- **소요자재량 계산**: 치수 기반 자동 산출
|
||||
- **작업지시서**: 인쇄용 레이아웃 (기존 품질관리 문서 패턴 활용)
|
||||
- **전개도 관리**: 형태(벽면형/측면형)별 기본 템플릿
|
||||
|
||||
---
|
||||
|
||||
### 2-4. 케이스 / 셔터박스 (Shutterbox)
|
||||
|
||||
#### 목록 페이지
|
||||
**필터 영역:**
|
||||
| 필터명 | 타입 | 옵션 |
|
||||
|--------|------|------|
|
||||
| 점검구 형태 | 토글 | 전체/양면 점검구/밑면 점검구/후면 점검구 |
|
||||
| 키워드 검색 | 텍스트 입력 | |
|
||||
|
||||
**테이블 컬럼:**
|
||||
| 순서 | 컬럼명 | 설명 |
|
||||
|------|--------|------|
|
||||
| 1 | 번호 | 순번 |
|
||||
| 2 | 등록일 | yyyy-MM-dd |
|
||||
| 3 | 박스(가로X세로) | 치수 (링크, 파란색) |
|
||||
| 4 | 점검구 형태 | 양면/밑면/후면 점검구 (색상 구분) |
|
||||
| 5 | 전면부 밑면 치수 | 숫자 |
|
||||
| 6 | 레일(폭) | 숫자 |
|
||||
| 7 | 소요자재량 | EGI 1.55T(2,652) 형식 |
|
||||
| 8 | 품목 검색어 | |
|
||||
| 9 | 형태 | 조립도 이미지 (가로세로 표기 포함) |
|
||||
| 10 | 작업지시서 | `보기` 버튼 |
|
||||
| 11 | 작성 | 작성자 |
|
||||
| 12 | 비고 | 메모 |
|
||||
|
||||
**특수 액션 버튼:**
|
||||
- `신규` → 등록 폼 팝업
|
||||
- `결합형태 이미지 등록` → 조립 이미지 관리
|
||||
- `점검구 형태별 기본 전개도` → 점검구 타입별 전개도 관리
|
||||
|
||||
#### 구현 포인트
|
||||
- **박스 치수 기반 자동계산**: 가로×세로 입력 시 각 부품별 소요자재량 자동 산출
|
||||
- **점검구 형태에 따른 전개도 차이**: 양면/밑면/후면 각각 다른 전개도 로직
|
||||
- **조립도 이미지**: 치수 표기 포함된 SVG/Canvas 렌더링
|
||||
|
||||
---
|
||||
|
||||
### 2-5. 하단마감재 (Bottombar)
|
||||
|
||||
#### 목록 페이지
|
||||
**필터 영역:**
|
||||
| 필터명 | 타입 | 옵션 |
|
||||
|--------|------|------|
|
||||
| 대분류 | 토글 | 전체/스크린/철재 |
|
||||
| 인정/비인정 | 토글 | 전체/인정/비인정 |
|
||||
| 모델 선택 | Select | (모델 선택) |
|
||||
| 키워드 검색 | 텍스트 입력 | |
|
||||
|
||||
**테이블 컬럼:**
|
||||
| 순서 | 컬럼명 | 설명 |
|
||||
|------|--------|------|
|
||||
| 1 | 번호 | 순번 |
|
||||
| 2 | 등록일 | yyyy-MM-dd |
|
||||
| 3 | 대분류 | 스크린/철재 |
|
||||
| 4 | 인정/비인정 | |
|
||||
| 5 | 제품코드 | KSS01, KTE01 등 |
|
||||
| 6 | 가로(폭) X 세로(높이) | 치수 (링크, 파란색) |
|
||||
| 7 | 품목검색어 | |
|
||||
| 8 | 마감형태 | SUS마감/EGI마감 (색상 강조) |
|
||||
| 9 | 소요자재량 | 재질별 소요량 |
|
||||
| 10 | 형태 | 전개도 이미지 (상세 치수 표기) |
|
||||
| 11 | 작업지시서 | `보기` 버튼 |
|
||||
| 12 | 작성 | 작성자 |
|
||||
| 13 | 비고 | 메모 |
|
||||
|
||||
**액션 버튼:**
|
||||
- `신규` → 등록 폼 팝업
|
||||
- `이미지 등록` → 형태 이미지 관리
|
||||
|
||||
#### 구현 포인트
|
||||
- 가이드레일과 구조가 유사 → 공통 컴포넌트 추출 가능
|
||||
- 하단마감재 전용 전개도 로직
|
||||
|
||||
---
|
||||
|
||||
### 2-6. 절곡 재고현황 (Bending Stock)
|
||||
|
||||
#### 대시보드 형태 페이지 (목록이 아닌 집계 화면)
|
||||
|
||||
**필터 영역:**
|
||||
| 필터명 | 타입 | 설명 |
|
||||
|--------|------|------|
|
||||
| 기간 시작 | DatePicker | 기본값: 2024.01.01 |
|
||||
| 기간 종료 | DatePicker | 기본값: 오늘 |
|
||||
| 품목명 | Select | |
|
||||
| 종류명 | Select | |
|
||||
| 모양&길이 | Select | |
|
||||
| 키워드 검색 | 텍스트 입력 | |
|
||||
| 그룹 선택 | 체크박스 그룹 | 전체선택/전체해제 + 가이드레일/케이스/하단마감재/기타 |
|
||||
|
||||
**전체 재고 요약 카드:**
|
||||
| 항목 | 색상 | 설명 |
|
||||
|------|------|------|
|
||||
| 총 생산량 | 파랑 | 전체 생산 수량 합계 |
|
||||
| 총 사용량 | 빨강 | 전체 사용(출고) 수량 합계 |
|
||||
| 총 재고량 | 초록 | 생산량 - 사용량 |
|
||||
|
||||
**그룹별 재고 현황 테이블 (4개 섹션):**
|
||||
1. 가이드레일 재고 현황
|
||||
2. 케이스 재고 현황
|
||||
3. 하단마감재 재고 현황
|
||||
4. 기타 재고 현황
|
||||
|
||||
각 테이블 컬럼:
|
||||
| 컬럼명 | 설명 |
|
||||
|--------|------|
|
||||
| 품목명 | 가이드레일(벽면형), 케이스 등 |
|
||||
| 종류 | C형, D형, 본체 등 |
|
||||
| 모양&길이 | 2438, 3000, 3500 등 |
|
||||
| 생산량 | 숫자 |
|
||||
| 품목코드 | RC24, RD30 등 |
|
||||
| 사용량 | 숫자 |
|
||||
| 재고량 | 숫자 (생산량-사용량) |
|
||||
| 상태 | "재고있음" / "재고없음" / "부족" |
|
||||
|
||||
**액션 버튼:**
|
||||
- `신규` → LOT 신규 등록
|
||||
- `작업일지` → 작업일지 페이지 이동
|
||||
- `업로드` → 일괄 업로드
|
||||
|
||||
#### 구현 포인트
|
||||
- **집계 데이터**: 서버 사이드 집계 API 필요
|
||||
- **그룹별 접기/펼치기**: Accordion 패턴
|
||||
- **상태 색상 코딩**: 재고있음(녹색), 부족(노란색), 재고없음(빨간색)
|
||||
- **체크박스 그룹 필터**: 실시간 테이블 섹션 토글
|
||||
|
||||
---
|
||||
|
||||
## 3. 공통 컴포넌트 / 패턴
|
||||
|
||||
### 3-1. 공통으로 추출할 컴포넌트
|
||||
|
||||
| 컴포넌트 | 사용처 | 설명 |
|
||||
|---------|--------|------|
|
||||
| `WorkOrderViewer` | 가이드레일, 케이스, 하단마감재 | 작업지시서 보기/인쇄 모달 |
|
||||
| `BlueprintImageManager` | 가이드레일, 케이스 | 결합형태 이미지 업로드/관리 |
|
||||
| `BendingFilterBar` | 바라시, 가이드레일, 하단마감재 | 대분류/인정 토글 + 모델 선택 필터 |
|
||||
| `MaterialCalculation` | 가이드레일, 케이스, 하단마감재 | 소요자재량 표시 컴포넌트 |
|
||||
| `LotNumberGenerator` | LOT 관리 | LOT 번호 자동생성 로직 |
|
||||
|
||||
### 3-2. SAM 기존 패턴 적용
|
||||
|
||||
| 요소 | SAM 패턴 | 적용 방법 |
|
||||
|------|---------|---------|
|
||||
| 목록 페이지 | IntegratedListTemplateV2 또는 UniversalListPage | 필터+테이블+페이지네이션 |
|
||||
| 등록/수정 | 모달 팝업 (버디가 팝업 사용) | Dialog 기반 폼 |
|
||||
| 필터 토글 | ToggleGroup (ui/) | 전체/스크린/철재 등 |
|
||||
| Select 필터 | Select (ui/) | 중분류, 품명, 모델 등 |
|
||||
| 테이블 | DataTable + 컬럼 설정 | useColumnSettings 적용 |
|
||||
| PDF 보기 | 기존 PDF 뷰어 또는 새 창 | 중간검사성적서 |
|
||||
| 날짜 | DatePicker (기존) | 등록일, 기간 필터 |
|
||||
|
||||
---
|
||||
|
||||
## 4. 백엔드 API 연동 예상
|
||||
|
||||
### 4-1. 필요 API 엔드포인트 (예상)
|
||||
|
||||
```
|
||||
# 절곡 바라시 기초자료
|
||||
GET /api/v1/bending ← 목록 조회 (필터/페이지네이션)
|
||||
GET /api/v1/bending/{id} ← 상세 조회
|
||||
POST /api/v1/bending ← 등록
|
||||
PUT /api/v1/bending/{id} ← 수정
|
||||
DELETE /api/v1/bending/{id} ← 삭제
|
||||
POST /api/v1/bending/{id}/copy ← 복사
|
||||
|
||||
# 재고생산/LOT
|
||||
GET /api/v1/bending-lot ← LOT 목록
|
||||
GET /api/v1/bending-lot/{id} ← LOT 상세
|
||||
POST /api/v1/bending-lot ← LOT 등록
|
||||
PUT /api/v1/bending-lot/{id} ← LOT 수정
|
||||
POST /api/v1/bending-lot/upload ← 일괄 업로드
|
||||
GET /api/v1/bending-lot/{id}/inspection-report ← 중간검사성적서 PDF
|
||||
|
||||
# 가이드레일
|
||||
GET /api/v1/guiderail ← 목록
|
||||
GET /api/v1/guiderail/{id} ← 상세
|
||||
POST /api/v1/guiderail ← 등록
|
||||
PUT /api/v1/guiderail/{id} ← 수정
|
||||
GET /api/v1/guiderail/{id}/work-order ← 작업지시서
|
||||
GET /api/v1/guiderail/blueprints ← 기본 전개도
|
||||
POST /api/v1/guiderail/blueprint-image ← 결합형태 이미지 등록
|
||||
|
||||
# 케이스 (셔터박스)
|
||||
GET /api/v1/shutterbox ← 목록
|
||||
GET /api/v1/shutterbox/{id} ← 상세
|
||||
POST /api/v1/shutterbox ← 등록
|
||||
PUT /api/v1/shutterbox/{id} ← 수정
|
||||
GET /api/v1/shutterbox/{id}/work-order ← 작업지시서
|
||||
GET /api/v1/shutterbox/blueprints ← 기본 전개도
|
||||
POST /api/v1/shutterbox/blueprint-image ← 결합형태 이미지 등록
|
||||
|
||||
# 하단마감재
|
||||
GET /api/v1/bottombar ← 목록
|
||||
GET /api/v1/bottombar/{id} ← 상세
|
||||
POST /api/v1/bottombar ← 등록
|
||||
PUT /api/v1/bottombar/{id} ← 수정
|
||||
GET /api/v1/bottombar/{id}/work-order ← 작업지시서
|
||||
POST /api/v1/bottombar/image ← 이미지 등록
|
||||
|
||||
# 절곡 재고현황
|
||||
GET /api/v1/bending-stock/summary ← 전체 재고 요약
|
||||
GET /api/v1/bending-stock/by-group ← 그룹별 재고 현황
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 구현 우선순위 및 단계
|
||||
|
||||
### Phase 1: 기초 인프라 + 단순 목록 (1주)
|
||||
- [ ] 라우트 구조 생성 (6개 page.tsx)
|
||||
- [ ] types.ts 정의 (전체 타입)
|
||||
- [ ] actions.ts 기본 구조 (API 연동 준비)
|
||||
- [ ] 하단마감재 목록/폼 (가장 단순, 11건)
|
||||
- [ ] 가이드레일 목록/폼
|
||||
|
||||
### Phase 2: 핵심 기능 (1주)
|
||||
- [ ] 케이스(셔터박스) 목록/폼
|
||||
- [ ] 작업지시서 보기 공통 컴포넌트
|
||||
- [ ] 결합형태 이미지 관리 공통 컴포넌트
|
||||
- [ ] 기본 전개도 뷰어
|
||||
|
||||
### Phase 3: 바라시 기초자료 (3~5일) ← 기존 그리기 도구 재활용으로 단축
|
||||
- [ ] 절곡 바라시 기초자료 목록
|
||||
- [ ] 절곡 바라시 등록 폼 (동적 행 + 자동계산)
|
||||
- [ ] 기존 DrawingCanvas 연동 (items/DrawingCanvas.tsx 그대로 import)
|
||||
- [ ] 이미지 붙여넣기 기능 (Clipboard API)
|
||||
|
||||
### Phase 4: LOT + 재고 (1주)
|
||||
- [ ] 재고생산/작업일지/중간검사성적서 목록/폼
|
||||
- [ ] LOT 번호 자동생성 로직
|
||||
- [ ] 중간검사성적서 PDF 연동
|
||||
- [ ] 절곡 재고현황 대시보드
|
||||
- [ ] 재고 요약 카드 + 그룹별 테이블
|
||||
|
||||
### Phase 5: 고도화 (선택)
|
||||
- [ ] 절곡 모델설정 페이지
|
||||
- [ ] 절곡 BOM 관리 페이지
|
||||
- [ ] 엑셀 업로드/다운로드
|
||||
- [ ] 인쇄 최적화
|
||||
|
||||
---
|
||||
|
||||
## 6. 리스크 및 주의사항
|
||||
|
||||
| 리스크 | 영향도 | 대응 |
|
||||
|--------|--------|------|
|
||||
| ~~그리기 도구 구현 복잡도~~ | ~~높음~~ | ✅ **해결**: 기존 DrawingCanvas 재활용 (품목관리에서 사용 중) |
|
||||
| API 엔드포인트 미확정 | 중간 | Mock 데이터로 UI 선구현 후 API 연동 |
|
||||
| LOT 번호 생성 규칙 확인 필요 | 중간 | 백엔드 팀과 규칙 확정 필요 |
|
||||
| 전개도/조립도 이미지 관리 | 낮음 | R2 스토리지 + 기존 BendingDiagramSection 패턴 재활용 |
|
||||
| 소요자재량 계산 로직 | 중간 | 백엔드 API에서 계산 후 반환 vs 프론트 계산 확정 필요 |
|
||||
|
||||
---
|
||||
|
||||
## 7. 확인 필요 사항 (백엔드 팀)
|
||||
|
||||
1. **API 엔드포인트 명명 규칙** 확정 (위 예상과 맞는지)
|
||||
2. **LOT 번호 자동생성 규칙** 정확한 로직 (프론트 생성 vs 백엔드 생성)
|
||||
3. **소요자재량 계산** 위치 (프론트 vs 백엔드)
|
||||
4. **중간검사성적서 PDF** 생성/저장 방식
|
||||
5. **작업지시서** 데이터 구조 및 생성 방식
|
||||
6. **절곡 모델설정 / BOM** 별도 페이지 여부 및 구조
|
||||
7. **이미지 저장** 경로 (R2 스토리지 활용?)
|
||||
5
src/app/[locale]/(protected)/approval/completed/page.tsx
Normal file
5
src/app/[locale]/(protected)/approval/completed/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { CompletedBox } from '@/components/approval/CompletedBox';
|
||||
|
||||
export default function ApprovalCompletedPage() {
|
||||
return <CompletedBox />;
|
||||
}
|
||||
@@ -202,7 +202,7 @@ export function BadDebtCollection({ initialData, initialSummary }: BadDebtCollec
|
||||
);
|
||||
},
|
||||
|
||||
// 필터 설정 (모바일용)
|
||||
// 필터 설정
|
||||
filterConfig: [
|
||||
{
|
||||
key: 'vendor',
|
||||
@@ -230,9 +230,9 @@ export function BadDebtCollection({ initialData, initialSummary }: BadDebtCollec
|
||||
},
|
||||
],
|
||||
initialFilters: {
|
||||
vendor: 'all',
|
||||
status: 'all',
|
||||
sortBy: 'latest',
|
||||
vendor: vendorFilter,
|
||||
status: statusFilter,
|
||||
sortBy: sortOption,
|
||||
},
|
||||
filterTitle: '악성채권 필터',
|
||||
|
||||
@@ -265,56 +265,6 @@ export function BadDebtCollection({ initialData, initialSummary }: BadDebtCollec
|
||||
return sorted;
|
||||
},
|
||||
|
||||
// 테이블 헤더 액션 (3개 필터)
|
||||
tableHeaderActions: () => (
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{/* 거래처 필터 */}
|
||||
<Select value={vendorFilter} onValueChange={setVendorFilter}>
|
||||
<SelectTrigger className="min-w-[150px] w-auto">
|
||||
<SelectValue placeholder="전체" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">전체</SelectItem>
|
||||
{vendorOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* 상태 필터 */}
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<SelectTrigger className="min-w-[120px] w-auto">
|
||||
<SelectValue placeholder="상태" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{STATUS_FILTER_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* 정렬 */}
|
||||
<Select
|
||||
value={sortOption}
|
||||
onValueChange={(value) => setSortOption(value as SortOption)}
|
||||
>
|
||||
<SelectTrigger className="min-w-[120px] w-auto">
|
||||
<SelectValue placeholder="정렬" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SORT_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
),
|
||||
|
||||
// Stats 카드
|
||||
computeStats: (): StatCard[] => [
|
||||
@@ -455,5 +405,15 @@ export function BadDebtCollection({ initialData, initialSummary }: BadDebtCollec
|
||||
]
|
||||
);
|
||||
|
||||
return <UniversalListPage config={config} initialData={data} />;
|
||||
return (
|
||||
<UniversalListPage
|
||||
config={config}
|
||||
initialData={data}
|
||||
onFilterChange={(filters) => {
|
||||
if (filters.vendor !== undefined) setVendorFilter(filters.vendor as string);
|
||||
if (filters.status !== undefined) setStatusFilter(filters.status as string);
|
||||
if (filters.sortBy !== undefined) setSortOption(filters.sortBy as SortOption);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -267,8 +267,14 @@ export function BillManagement({ initialVendorId, initialBillType }: BillManagem
|
||||
// 검색 필터
|
||||
searchPlaceholder: '어음번호, 거래처, 메모 검색...',
|
||||
|
||||
// 필터 설정 (모바일 필터 시트용)
|
||||
// 필터 설정
|
||||
filterConfig: [
|
||||
{
|
||||
key: 'vendor',
|
||||
label: '거래처',
|
||||
type: 'single',
|
||||
options: vendorOptions.filter(o => o.value !== 'all'),
|
||||
},
|
||||
{
|
||||
key: 'billType',
|
||||
label: '구분',
|
||||
@@ -289,8 +295,9 @@ export function BillManagement({ initialVendorId, initialBillType }: BillManagem
|
||||
},
|
||||
],
|
||||
initialFilters: {
|
||||
vendor: vendorFilter,
|
||||
billType: initialBillType || 'received',
|
||||
status: 'all',
|
||||
status: statusFilter,
|
||||
},
|
||||
filterTitle: '어음 필터',
|
||||
|
||||
@@ -345,33 +352,16 @@ export function BillManagement({ initialVendorId, initialBillType }: BillManagem
|
||||
</div>
|
||||
),
|
||||
|
||||
// tableHeaderActions: 저장 버튼 + 거래처 필터
|
||||
// tableHeaderActions: 저장 버튼
|
||||
tableHeaderActions: ({ selectedItems }) => (
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{/* 저장 버튼 */}
|
||||
<Button
|
||||
onClick={() => handleSave(selectedItems)}
|
||||
className="bg-blue-500 hover:bg-blue-600"
|
||||
disabled={isSaving}
|
||||
>
|
||||
<Save className="h-4 w-4 mr-2" />
|
||||
{isSaving ? '저장중...' : '저장'}
|
||||
</Button>
|
||||
|
||||
{/* 거래처명 필터 */}
|
||||
<Select value={vendorFilter} onValueChange={setVendorFilter}>
|
||||
<SelectTrigger className="min-w-[120px] w-auto">
|
||||
<SelectValue placeholder="거래처명" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{vendorOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => handleSave(selectedItems)}
|
||||
className="bg-blue-500 hover:bg-blue-600"
|
||||
disabled={isSaving}
|
||||
>
|
||||
<Save className="h-4 w-4 mr-2" />
|
||||
{isSaving ? '저장중...' : '저장'}
|
||||
</Button>
|
||||
),
|
||||
|
||||
// 삭제 확인 메시지
|
||||
@@ -519,6 +509,11 @@ export function BillManagement({ initialVendorId, initialBillType }: BillManagem
|
||||
onPageChange: setCurrentPage,
|
||||
}}
|
||||
externalIsLoading={isLoading}
|
||||
onFilterChange={(filters) => {
|
||||
if (filters.vendor !== undefined) setVendorFilter(filters.vendor as string);
|
||||
if (filters.billType !== undefined) setBillTypeFilter(filters.billType as string);
|
||||
if (filters.status !== undefined) setStatusFilter(filters.status as string);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { formatNumber } from '@/lib/utils/amount';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { CurrencyInput } from '@/components/ui/currency-input';
|
||||
import { FormField } from '@/components/molecules/FormField';
|
||||
import {
|
||||
Dialog,
|
||||
@@ -167,28 +168,32 @@ export function JournalEntryModal({ open, onOpenChange, transaction, onSuccess }
|
||||
|
||||
{/* 공급가액 + 세액 + 합계금액 */}
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<FormField
|
||||
type="number"
|
||||
label="공급가액"
|
||||
value={item.supplyAmount || ''}
|
||||
onChange={(v) => updateItem(index, 'supplyAmount', Number(v) || 0)}
|
||||
inputClassName="h-8 text-sm"
|
||||
/>
|
||||
<FormField
|
||||
type="number"
|
||||
label="세액"
|
||||
value={item.taxAmount || ''}
|
||||
onChange={(v) => updateItem(index, 'taxAmount', Number(v) || 0)}
|
||||
inputClassName="h-8 text-sm"
|
||||
/>
|
||||
{/* 합계금액 - readOnly (FormField 미지원, 커스텀 인터랙션 예외) */}
|
||||
<div>
|
||||
<Label className="text-xs">공급가액</Label>
|
||||
<CurrencyInput
|
||||
value={item.supplyAmount}
|
||||
onChange={(v) => updateItem(index, 'supplyAmount', v ?? 0)}
|
||||
className="mt-1 h-8 text-sm"
|
||||
showCurrency={false}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">세액</Label>
|
||||
<CurrencyInput
|
||||
value={item.taxAmount}
|
||||
onChange={(v) => updateItem(index, 'taxAmount', v ?? 0)}
|
||||
className="mt-1 h-8 text-sm"
|
||||
showCurrency={false}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">합계금액</Label>
|
||||
<Input
|
||||
type="number"
|
||||
<CurrencyInput
|
||||
value={item.totalAmount}
|
||||
readOnly
|
||||
onChange={() => {}}
|
||||
disabled
|
||||
className="mt-1 h-8 text-sm bg-muted/50"
|
||||
showCurrency={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { formatNumber } from '@/lib/utils/amount';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { CurrencyInput } from '@/components/ui/currency-input';
|
||||
import { FormField } from '@/components/molecules/FormField';
|
||||
import { DatePicker } from '@/components/ui/date-picker';
|
||||
import { TimePicker } from '@/components/ui/time-picker';
|
||||
@@ -198,22 +199,22 @@ export function ManualInputModal({ open, onOpenChange, onSuccess }: ManualInputM
|
||||
|
||||
{/* 공급가액 + 세액 */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<FormField
|
||||
type="number"
|
||||
label="공급가액"
|
||||
required
|
||||
value={formData.supplyAmount || ''}
|
||||
onChange={(v) => handleChange('supplyAmount', Number(v) || 0)}
|
||||
placeholder="0"
|
||||
/>
|
||||
<FormField
|
||||
type="number"
|
||||
label="세액"
|
||||
required
|
||||
value={formData.taxAmount || ''}
|
||||
onChange={(v) => handleChange('taxAmount', Number(v) || 0)}
|
||||
placeholder="0"
|
||||
/>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">공급가액 <span className="text-red-500">*</span></Label>
|
||||
<CurrencyInput
|
||||
value={formData.supplyAmount}
|
||||
onChange={(v) => handleChange('supplyAmount', v ?? 0)}
|
||||
showCurrency={false}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">세액 <span className="text-red-500">*</span></Label>
|
||||
<CurrencyInput
|
||||
value={formData.taxAmount}
|
||||
onChange={(v) => handleChange('taxAmount', v ?? 0)}
|
||||
showCurrency={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 가맹점명 + 사업자번호 */}
|
||||
|
||||
@@ -284,8 +284,14 @@ export function DepositManagement({ initialData, initialPagination: _initialPagi
|
||||
onEndDateChange: setEndDate,
|
||||
},
|
||||
|
||||
// 모바일 필터 설정
|
||||
// 필터 설정
|
||||
filterConfig: [
|
||||
{
|
||||
key: 'vendor',
|
||||
label: '거래처',
|
||||
type: 'single',
|
||||
options: vendorOptions.filter(o => o.value !== 'all'),
|
||||
},
|
||||
{
|
||||
key: 'depositType',
|
||||
label: '입금유형',
|
||||
@@ -300,6 +306,7 @@ export function DepositManagement({ initialData, initialPagination: _initialPagi
|
||||
},
|
||||
],
|
||||
initialFilters: {
|
||||
vendor: vendorFilter,
|
||||
depositType: depositTypeFilter,
|
||||
sortBy: sortOption,
|
||||
},
|
||||
@@ -352,52 +359,6 @@ export function DepositManagement({ initialData, initialPagination: _initialPagi
|
||||
{ label: '입금유형 미설정', value: `${stats.depositTypeUnsetCount}건`, icon: Banknote, iconColor: 'text-red-500' },
|
||||
],
|
||||
|
||||
// tableHeaderActions: 3개 인라인 필터
|
||||
tableHeaderActions: (
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{/* 거래처 필터 */}
|
||||
<Select value={vendorFilter} onValueChange={setVendorFilter}>
|
||||
<SelectTrigger className="min-w-[140px] w-auto">
|
||||
<SelectValue placeholder="거래처" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{vendorOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* 입금유형 필터 */}
|
||||
<Select value={depositTypeFilter} onValueChange={setDepositTypeFilter}>
|
||||
<SelectTrigger className="min-w-[130px] w-auto">
|
||||
<SelectValue placeholder="입금유형" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{DEPOSIT_TYPE_FILTER_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* 정렬 */}
|
||||
<Select value={sortOption} onValueChange={(value) => setSortOption(value as SortOption)}>
|
||||
<SelectTrigger className="min-w-[120px] w-auto">
|
||||
<SelectValue placeholder="정렬" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SORT_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
),
|
||||
|
||||
// 테이블 하단 합계 행
|
||||
tableFooter: (
|
||||
@@ -502,7 +463,15 @@ export function DepositManagement({ initialData, initialPagination: _initialPagi
|
||||
|
||||
return (
|
||||
<>
|
||||
<UniversalListPage config={config} initialData={initialData} />
|
||||
<UniversalListPage
|
||||
config={config}
|
||||
initialData={initialData}
|
||||
onFilterChange={(filters) => {
|
||||
if (filters.vendor !== undefined) setVendorFilter(filters.vendor as string);
|
||||
if (filters.depositType !== undefined) setDepositTypeFilter(filters.depositType as string);
|
||||
if (filters.sortBy !== undefined) setSortOption(filters.sortBy as SortOption);
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 계정과목명 저장 확인 다이얼로그 */}
|
||||
<Dialog open={showSaveDialog} onOpenChange={setShowSaveDialog}>
|
||||
|
||||
@@ -856,8 +856,14 @@ export function ExpectedExpenseManagement({
|
||||
onEndDateChange: setEndDate,
|
||||
},
|
||||
|
||||
// 모바일 필터 설정
|
||||
// 필터 설정
|
||||
filterConfig: [
|
||||
{
|
||||
key: 'vendor',
|
||||
label: '거래처',
|
||||
type: 'single',
|
||||
options: vendorFilterOptions.filter(o => o.value !== 'all'),
|
||||
},
|
||||
{
|
||||
key: 'transactionType',
|
||||
label: '거래유형',
|
||||
@@ -878,6 +884,7 @@ export function ExpectedExpenseManagement({
|
||||
},
|
||||
],
|
||||
initialFilters: {
|
||||
vendor: vendorFilter,
|
||||
transactionType: 'all',
|
||||
paymentStatus: 'all',
|
||||
sortBy: sortOption,
|
||||
@@ -930,38 +937,6 @@ export function ExpectedExpenseManagement({
|
||||
onClick: handleOpenCreateDialog,
|
||||
},
|
||||
|
||||
// 테이블 헤더 액션 (거래처/정렬 필터)
|
||||
tableHeaderActions: () => (
|
||||
<div className="flex items-center gap-2">
|
||||
{/* 거래처 필터 */}
|
||||
<Select value={vendorFilter} onValueChange={setVendorFilter}>
|
||||
<SelectTrigger className="min-w-[140px] w-auto h-8 text-sm">
|
||||
<SelectValue placeholder="전체" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{vendorFilterOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* 정렬 필터 (최신순/등록순) */}
|
||||
<Select value={sortOption} onValueChange={(value) => setSortOption(value as SortOption)}>
|
||||
<SelectTrigger className="min-w-[100px] w-auto h-8 text-sm">
|
||||
<SelectValue placeholder="최신순" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SORT_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
),
|
||||
|
||||
// Stats 카드
|
||||
computeStats: (): StatCard[] => {
|
||||
@@ -1018,6 +993,10 @@ export function ExpectedExpenseManagement({
|
||||
onToggleSelectAll: toggleSelectAll,
|
||||
getItemId: (item: TableRowData) => item.id,
|
||||
}}
|
||||
onFilterChange={(filters) => {
|
||||
if (filters.vendor !== undefined) setVendorFilter(filters.vendor as string);
|
||||
if (filters.sortBy !== undefined) setSortOption(filters.sortBy as SortOption);
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 삭제 확인 다이얼로그 */}
|
||||
|
||||
@@ -17,6 +17,7 @@ import { formatNumber } from '@/lib/utils/amount';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { CurrencyInput } from '@/components/ui/currency-input';
|
||||
import { FormField } from '@/components/molecules/FormField';
|
||||
import {
|
||||
Dialog,
|
||||
@@ -385,27 +386,25 @@ export function JournalEditModal({
|
||||
</Select>
|
||||
</TableCell>
|
||||
<TableCell className="p-1">
|
||||
<Input
|
||||
type="number"
|
||||
value={row.debitAmount || ''}
|
||||
onChange={(e) =>
|
||||
handleRowChange(row.id, 'debitAmount', Number(e.target.value) || 0)
|
||||
<CurrencyInput
|
||||
value={row.debitAmount || 0}
|
||||
onChange={(v) =>
|
||||
handleRowChange(row.id, 'debitAmount', v ?? 0)
|
||||
}
|
||||
disabled={row.side === 'credit'}
|
||||
className="h-8 text-sm text-right"
|
||||
placeholder="0"
|
||||
className="h-8 text-sm"
|
||||
showCurrency={false}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="p-1">
|
||||
<Input
|
||||
type="number"
|
||||
value={row.creditAmount || ''}
|
||||
onChange={(e) =>
|
||||
handleRowChange(row.id, 'creditAmount', Number(e.target.value) || 0)
|
||||
<CurrencyInput
|
||||
value={row.creditAmount || 0}
|
||||
onChange={(v) =>
|
||||
handleRowChange(row.id, 'creditAmount', v ?? 0)
|
||||
}
|
||||
disabled={row.side === 'debit'}
|
||||
className="h-8 text-sm text-right"
|
||||
placeholder="0"
|
||||
className="h-8 text-sm"
|
||||
showCurrency={false}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="p-1">
|
||||
|
||||
@@ -17,6 +17,7 @@ import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { DatePicker } from '@/components/ui/date-picker';
|
||||
import { CurrencyInput } from '@/components/ui/currency-input';
|
||||
import { FormField } from '@/components/molecules/FormField';
|
||||
import {
|
||||
Dialog,
|
||||
@@ -296,27 +297,25 @@ export function ManualJournalEntryModal({
|
||||
</Select>
|
||||
</TableCell>
|
||||
<TableCell className="p-1">
|
||||
<Input
|
||||
type="number"
|
||||
value={row.debitAmount || ''}
|
||||
onChange={(e) =>
|
||||
handleRowChange(row.id, 'debitAmount', Number(e.target.value) || 0)
|
||||
<CurrencyInput
|
||||
value={row.debitAmount || 0}
|
||||
onChange={(v) =>
|
||||
handleRowChange(row.id, 'debitAmount', v ?? 0)
|
||||
}
|
||||
disabled={row.side === 'credit'}
|
||||
className="h-8 text-sm text-right"
|
||||
placeholder="0"
|
||||
className="h-8 text-sm"
|
||||
showCurrency={false}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="p-1">
|
||||
<Input
|
||||
type="number"
|
||||
value={row.creditAmount || ''}
|
||||
onChange={(e) =>
|
||||
handleRowChange(row.id, 'creditAmount', Number(e.target.value) || 0)
|
||||
<CurrencyInput
|
||||
value={row.creditAmount || 0}
|
||||
onChange={(v) =>
|
||||
handleRowChange(row.id, 'creditAmount', v ?? 0)
|
||||
}
|
||||
disabled={row.side === 'debit'}
|
||||
className="h-8 text-sm text-right"
|
||||
placeholder="0"
|
||||
className="h-8 text-sm"
|
||||
showCurrency={false}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="p-1">
|
||||
|
||||
@@ -159,7 +159,7 @@ export function PurchaseManagement() {
|
||||
label: '세금계산서 수취여부',
|
||||
type: 'single',
|
||||
options: TAX_INVOICE_RECEIVED_FILTER_OPTIONS.filter(o => o.value !== 'all'),
|
||||
allOptionLabel: '전체',
|
||||
allOptionLabel: '세금계산서 전체',
|
||||
},
|
||||
{
|
||||
key: 'sort',
|
||||
|
||||
@@ -145,14 +145,14 @@ export function SalesManagement({ initialData, initialPagination }: SalesManagem
|
||||
label: '세금계산서 발행여부',
|
||||
type: 'single',
|
||||
options: TAX_INVOICE_FILTER_OPTIONS.filter(o => o.value !== 'all'),
|
||||
allOptionLabel: '전체',
|
||||
allOptionLabel: '세금계산서 전체',
|
||||
},
|
||||
{
|
||||
key: 'transactionStatement',
|
||||
label: '거래명세서 발행여부',
|
||||
type: 'single',
|
||||
options: TRANSACTION_STATEMENT_FILTER_OPTIONS.filter(o => o.value !== 'all'),
|
||||
allOptionLabel: '전체',
|
||||
allOptionLabel: '거래명세서 전체',
|
||||
},
|
||||
{
|
||||
key: 'sort',
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useCallback } from 'react';
|
||||
import { formatNumber } from '@/lib/utils/amount';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { CurrencyInput } from '@/components/ui/currency-input';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -153,13 +154,11 @@ export function TaxInvoiceItemTable({ items, onItemsChange }: TaxInvoiceItemTabl
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="p-1">
|
||||
<Input
|
||||
type="number"
|
||||
value={item.unitPrice || ''}
|
||||
onChange={(e) => updateItem(item.id, 'unitPrice', Number(e.target.value) || 0)}
|
||||
placeholder="0"
|
||||
className="h-8 text-sm text-right"
|
||||
min={0}
|
||||
<CurrencyInput
|
||||
value={item.unitPrice || 0}
|
||||
onChange={(v) => updateItem(item.id, 'unitPrice', v ?? 0)}
|
||||
className="h-8 text-sm"
|
||||
showCurrency={false}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="p-1 text-right text-sm font-medium">
|
||||
|
||||
@@ -17,6 +17,7 @@ import { CreditCard } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { DatePicker } from '@/components/ui/date-picker';
|
||||
import { CurrencyInput } from '@/components/ui/currency-input';
|
||||
import { FormField } from '@/components/molecules/FormField';
|
||||
import { BusinessNumberInput } from '@/components/ui/business-number-input';
|
||||
import {
|
||||
@@ -84,10 +85,9 @@ export function ManualEntryModal({
|
||||
|
||||
// 공급가액/세액 변경 시 합계 자동 계산
|
||||
const handleAmountChange = useCallback(
|
||||
(field: 'supplyAmount' | 'taxAmount', value: string) => {
|
||||
const numValue = Number(value) || 0;
|
||||
(field: 'supplyAmount' | 'taxAmount', value: number) => {
|
||||
setFormData((prev) => {
|
||||
const updated = { ...prev, [field]: numValue };
|
||||
const updated = { ...prev, [field]: value };
|
||||
updated.totalAmount = updated.supplyAmount + updated.taxAmount;
|
||||
return updated;
|
||||
});
|
||||
@@ -213,27 +213,32 @@ export function ManualEntryModal({
|
||||
|
||||
{/* 금액 */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
<FormField
|
||||
label="공급가액"
|
||||
type="number"
|
||||
value={formData.supplyAmount || ''}
|
||||
onChange={(value) => handleAmountChange('supplyAmount', value)}
|
||||
placeholder="0"
|
||||
/>
|
||||
<FormField
|
||||
label="세액"
|
||||
type="number"
|
||||
value={formData.taxAmount || ''}
|
||||
onChange={(value) => handleAmountChange('taxAmount', value)}
|
||||
placeholder="0"
|
||||
/>
|
||||
<FormField
|
||||
label="합계"
|
||||
type="number"
|
||||
value={formData.totalAmount || ''}
|
||||
disabled
|
||||
inputClassName="bg-muted"
|
||||
/>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">공급가액</Label>
|
||||
<CurrencyInput
|
||||
value={formData.supplyAmount}
|
||||
onChange={(v) => handleAmountChange('supplyAmount', v ?? 0)}
|
||||
showCurrency={false}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">세액</Label>
|
||||
<CurrencyInput
|
||||
value={formData.taxAmount}
|
||||
onChange={(v) => handleAmountChange('taxAmount', v ?? 0)}
|
||||
showCurrency={false}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">합계</Label>
|
||||
<CurrencyInput
|
||||
value={formData.totalAmount}
|
||||
onChange={() => {}}
|
||||
disabled
|
||||
showCurrency={false}
|
||||
className="bg-muted"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 품목 + 과세유형 */}
|
||||
|
||||
@@ -407,7 +407,7 @@ export function VendorManagementClient({ initialData, initialTotal: _initialTota
|
||||
searchPlaceholder: '거래처명, 거래처코드, 사업자번호 검색...',
|
||||
onSearchChange: setSearchQuery,
|
||||
|
||||
// 모바일 필터 설정
|
||||
// 필터 설정
|
||||
filterConfig: [
|
||||
{
|
||||
key: 'category',
|
||||
@@ -452,81 +452,6 @@ export function VendorManagementClient({ initialData, initialTotal: _initialTota
|
||||
// 통계 카드
|
||||
computeStats: (): StatCard[] => statCards,
|
||||
|
||||
// 테이블 헤더 액션 (5개 필터)
|
||||
tableHeaderActions: (
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{/* 구분 필터 */}
|
||||
<Select value={categoryFilter} onValueChange={setCategoryFilter}>
|
||||
<SelectTrigger className="min-w-[120px] w-auto">
|
||||
<SelectValue placeholder="구분" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{VENDOR_CATEGORY_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* 신용등급 필터 */}
|
||||
<Select value={creditRatingFilter} onValueChange={setCreditRatingFilter}>
|
||||
<SelectTrigger className="min-w-[110px] w-auto">
|
||||
<SelectValue placeholder="신용등급" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{CREDIT_RATING_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* 거래등급 필터 */}
|
||||
<Select value={transactionGradeFilter} onValueChange={setTransactionGradeFilter}>
|
||||
<SelectTrigger className="min-w-[120px] w-auto">
|
||||
<SelectValue placeholder="거래등급" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{TRANSACTION_GRADE_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* 악성채권 필터 */}
|
||||
<Select value={badDebtFilter} onValueChange={setBadDebtFilter}>
|
||||
<SelectTrigger className="min-w-[110px] w-auto">
|
||||
<SelectValue placeholder="악성채권" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{BAD_DEBT_STATUS_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* 정렬 */}
|
||||
<Select value={sortOption} onValueChange={(value) => setSortOption(value as SortOption)}>
|
||||
<SelectTrigger className="min-w-[150px] w-auto">
|
||||
<SelectValue placeholder="정렬" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SORT_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
),
|
||||
|
||||
// 렌더링 함수
|
||||
renderTableRow,
|
||||
renderMobileCard,
|
||||
@@ -567,6 +492,13 @@ export function VendorManagementClient({ initialData, initialTotal: _initialTota
|
||||
onToggleSelectAll: toggleSelectAll,
|
||||
getItemId: (item: Vendor) => item.id,
|
||||
}}
|
||||
onFilterChange={(filters) => {
|
||||
if (filters.category !== undefined) setCategoryFilter(filters.category as string);
|
||||
if (filters.creditRating !== undefined) setCreditRatingFilter(filters.creditRating as string);
|
||||
if (filters.transactionGrade !== undefined) setTransactionGradeFilter(filters.transactionGrade as string);
|
||||
if (filters.badDebt !== undefined) setBadDebtFilter(filters.badDebt as string);
|
||||
if (filters.sortBy !== undefined) setSortOption(filters.sortBy as SortOption);
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 삭제 확인 다이얼로그 */}
|
||||
|
||||
@@ -270,8 +270,14 @@ export function WithdrawalManagement({ initialData, initialPagination: _initialP
|
||||
);
|
||||
},
|
||||
|
||||
// 필터 설정 (모바일 필터 시트용)
|
||||
// 필터 설정
|
||||
filterConfig: [
|
||||
{
|
||||
key: 'vendor',
|
||||
label: '거래처',
|
||||
type: 'single',
|
||||
options: vendorOptions.filter(o => o.value !== 'all'),
|
||||
},
|
||||
{
|
||||
key: 'withdrawalType',
|
||||
label: '출금유형',
|
||||
@@ -292,8 +298,9 @@ export function WithdrawalManagement({ initialData, initialPagination: _initialP
|
||||
},
|
||||
],
|
||||
initialFilters: {
|
||||
withdrawalType: 'all',
|
||||
sortBy: 'latest',
|
||||
vendor: vendorFilter,
|
||||
withdrawalType: withdrawalTypeFilter,
|
||||
sortBy: sortOption,
|
||||
},
|
||||
filterTitle: '출금 필터',
|
||||
|
||||
@@ -369,52 +376,6 @@ export function WithdrawalManagement({ initialData, initialPagination: _initialP
|
||||
onClick: () => router.push('/ko/accounting/withdrawals?mode=new'),
|
||||
},
|
||||
|
||||
// tableHeaderActions: 필터만 (거래처, 출금유형, 정렬)
|
||||
tableHeaderActions: () => (
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{/* 거래처 필터 */}
|
||||
<Select value={vendorFilter} onValueChange={setVendorFilter}>
|
||||
<SelectTrigger className="min-w-[140px] w-auto">
|
||||
<SelectValue placeholder="거래처" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{vendorOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* 출금유형 필터 */}
|
||||
<Select value={withdrawalTypeFilter} onValueChange={setWithdrawalTypeFilter}>
|
||||
<SelectTrigger className="min-w-[130px] w-auto">
|
||||
<SelectValue placeholder="출금유형" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{WITHDRAWAL_TYPE_FILTER_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* 정렬 */}
|
||||
<Select value={sortOption} onValueChange={(value) => setSortOption(value as SortOption)}>
|
||||
<SelectTrigger className="min-w-[120px] w-auto">
|
||||
<SelectValue placeholder="정렬" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SORT_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
),
|
||||
|
||||
// tableFooter: 합계 행
|
||||
tableFooter: (
|
||||
@@ -554,7 +515,15 @@ export function WithdrawalManagement({ initialData, initialPagination: _initialP
|
||||
|
||||
return (
|
||||
<>
|
||||
<UniversalListPage config={config} initialData={initialData} />
|
||||
<UniversalListPage
|
||||
config={config}
|
||||
initialData={initialData}
|
||||
onFilterChange={(filters) => {
|
||||
if (filters.vendor !== undefined) setVendorFilter(filters.vendor as string);
|
||||
if (filters.withdrawalType !== undefined) setWithdrawalTypeFilter(filters.withdrawalType as string);
|
||||
if (filters.sortBy !== undefined) setSortOption(filters.sortBy as SortOption);
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 계정과목명 저장 확인 다이얼로그 */}
|
||||
<Dialog open={showSaveDialog} onOpenChange={setShowSaveDialog}>
|
||||
|
||||
@@ -22,8 +22,9 @@ import type { ApprovalRecord, ApprovalType, ApprovalStatus } from './types';
|
||||
|
||||
interface InboxSummary {
|
||||
total: number;
|
||||
pending: number;
|
||||
approved: number;
|
||||
requested: number;
|
||||
scheduled: number;
|
||||
completed: number;
|
||||
rejected: number;
|
||||
}
|
||||
|
||||
@@ -56,14 +57,16 @@ interface InboxStepApiData {
|
||||
|
||||
function mapApiStatus(apiStatus: string): ApprovalStatus {
|
||||
const statusMap: Record<string, ApprovalStatus> = {
|
||||
'pending': 'pending', 'approved': 'approved', 'rejected': 'rejected',
|
||||
'requested': 'requested', 'scheduled': 'scheduled', 'completed': 'completed', 'rejected': 'rejected',
|
||||
// legacy fallback
|
||||
'pending': 'requested', 'approved': 'completed',
|
||||
};
|
||||
return statusMap[apiStatus] || 'pending';
|
||||
return statusMap[apiStatus] || 'requested';
|
||||
}
|
||||
|
||||
function mapTabToApiStatus(tabStatus: string): string | undefined {
|
||||
const statusMap: Record<string, string> = {
|
||||
'pending': 'requested', 'approved': 'completed', 'rejected': 'rejected',
|
||||
'requested': 'requested', 'scheduled': 'scheduled', 'completed': 'completed', 'rejected': 'rejected',
|
||||
};
|
||||
return statusMap[tabStatus];
|
||||
}
|
||||
@@ -78,6 +81,7 @@ function mapApprovalType(formCategory?: string): ApprovalType {
|
||||
function mapDocumentStatus(status: string): string {
|
||||
const statusMap: Record<string, string> = {
|
||||
'pending': '진행중', 'approved': '완료', 'rejected': '반려',
|
||||
'cancelled': '회수', 'on_hold': '보류',
|
||||
};
|
||||
return statusMap[status] || '진행중';
|
||||
}
|
||||
@@ -91,6 +95,8 @@ function transformApiToFrontend(data: InboxApiData): ApprovalRecord {
|
||||
id: String(data.id),
|
||||
documentNo: data.document_number,
|
||||
approvalType: mapApprovalType(data.form?.category),
|
||||
formCode: data.form?.code,
|
||||
formName: data.form?.name,
|
||||
documentStatus: mapDocumentStatus(data.status),
|
||||
title: data.title,
|
||||
draftDate: data.created_at.replace('T', ' ').substring(0, 16),
|
||||
@@ -300,6 +306,37 @@ export async function getDocumentApprovalById(id: number): Promise<{
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 워크플로우 액션 (보류/보류해제/전결)
|
||||
// ============================================
|
||||
|
||||
export async function holdDocument(id: string, comment?: string): Promise<ActionResult> {
|
||||
return executeServerAction({
|
||||
url: buildApiUrl(`/api/v1/approvals/${id}/hold`),
|
||||
method: 'POST',
|
||||
body: { comment: comment || '' },
|
||||
errorMessage: '보류 처리에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
export async function releaseHoldDocument(id: string, comment?: string): Promise<ActionResult> {
|
||||
return executeServerAction({
|
||||
url: buildApiUrl(`/api/v1/approvals/${id}/release-hold`),
|
||||
method: 'POST',
|
||||
body: { comment: comment || '' },
|
||||
errorMessage: '보류 해제에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
export async function preDecideDocument(id: string, comment?: string): Promise<ActionResult> {
|
||||
return executeServerAction({
|
||||
url: buildApiUrl(`/api/v1/approvals/${id}/pre-decide`),
|
||||
method: 'POST',
|
||||
body: { comment: comment || '' },
|
||||
errorMessage: '전결 처리에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
export async function rejectDocumentsBulk(ids: string[], comment: string): Promise<{ success: boolean; failedIds?: string[]; error?: string }> {
|
||||
if (!comment?.trim()) return { success: false, error: '반려 사유를 입력해주세요.' };
|
||||
const failedIds: string[] = [];
|
||||
|
||||
@@ -27,13 +27,6 @@ import { Badge } from '@/components/ui/badge';
|
||||
import { TableRow, TableCell } from '@/components/ui/table';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
@@ -59,7 +52,10 @@ import type {
|
||||
ExpenseReportDocumentData,
|
||||
ExpenseEstimateDocumentData,
|
||||
LinkedDocumentData,
|
||||
DynamicDocumentData,
|
||||
} from '@/components/approval/DocumentDetail/types';
|
||||
import { getFieldLabels, filterVisibleFields, getFormName } from '@/components/approval/DocumentDetail/field-labels';
|
||||
import { DEDICATED_FORM_CODES } from '@/components/approval/DocumentCreate/types';
|
||||
import type {
|
||||
ApprovalTabType,
|
||||
ApprovalRecord,
|
||||
@@ -105,7 +101,8 @@ export function ApprovalBox() {
|
||||
// ===== 문서 상세 모달 상태 =====
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [selectedDocument, setSelectedDocument] = useState<ApprovalRecord | null>(null);
|
||||
const [modalData, setModalData] = useState<ProposalDocumentData | ExpenseReportDocumentData | ExpenseEstimateDocumentData | LinkedDocumentData | null>(null);
|
||||
const [modalData, setModalData] = useState<ProposalDocumentData | ExpenseReportDocumentData | ExpenseEstimateDocumentData | LinkedDocumentData | DynamicDocumentData | null>(null);
|
||||
const [modalDocTypeOverride, setModalDocTypeOverride] = useState<DocumentType | null>(null);
|
||||
const [, setIsModalLoading] = useState(false);
|
||||
|
||||
// ===== 검사성적서 모달 상태 (work_order 연결 문서용) =====
|
||||
@@ -120,7 +117,7 @@ export function ApprovalBox() {
|
||||
const isInitialLoadDone = useRef(false);
|
||||
|
||||
// 통계 데이터
|
||||
const [fixedStats, setFixedStats] = useState({ all: 0, pending: 0, approved: 0, rejected: 0 });
|
||||
const [fixedStats, setFixedStats] = useState({ all: 0, requested: 0, scheduled: 0, completed: 0, rejected: 0 });
|
||||
|
||||
// ===== 데이터 로드 =====
|
||||
const loadData = useCallback(async () => {
|
||||
@@ -186,14 +183,16 @@ export function ApprovalBox() {
|
||||
// ===== 전체 탭일 때만 통계 업데이트 =====
|
||||
useEffect(() => {
|
||||
if (activeTab === 'all' && data.length > 0) {
|
||||
const pending = data.filter((item) => item.status === 'pending').length;
|
||||
const approved = data.filter((item) => item.status === 'approved').length;
|
||||
const requested = data.filter((item) => item.status === 'requested').length;
|
||||
const scheduled = data.filter((item) => item.status === 'scheduled').length;
|
||||
const completed = data.filter((item) => item.status === 'completed').length;
|
||||
const rejected = data.filter((item) => item.status === 'rejected').length;
|
||||
|
||||
setFixedStats({
|
||||
all: totalCount,
|
||||
pending,
|
||||
approved,
|
||||
requested,
|
||||
scheduled,
|
||||
completed,
|
||||
rejected,
|
||||
});
|
||||
}
|
||||
@@ -308,11 +307,11 @@ export function ApprovalBox() {
|
||||
return;
|
||||
}
|
||||
|
||||
// 기존 결재 문서 타입 (품의서, 지출결의서, 지출예상내역서)
|
||||
// 결재 문서 조회 (품의서, 지출결의서, 비용견적서, 전용 양식 등)
|
||||
const result = await getApprovalById(parseInt(item.id));
|
||||
if (result.success && result.data) {
|
||||
const formData = result.data;
|
||||
const docType = getDocumentType(item.approvalType);
|
||||
const docTypeCode = formData.basicInfo.documentType;
|
||||
|
||||
// 기안자 정보
|
||||
const drafter = {
|
||||
@@ -330,7 +329,7 @@ export function ApprovalBox() {
|
||||
position: person.position,
|
||||
department: person.department,
|
||||
status:
|
||||
item.status === 'approved'
|
||||
item.status === 'completed'
|
||||
? ('approved' as const)
|
||||
: item.status === 'rejected'
|
||||
? ('rejected' as const)
|
||||
@@ -339,10 +338,52 @@ export function ApprovalBox() {
|
||||
: ('none' as const),
|
||||
}));
|
||||
|
||||
let convertedData: ProposalDocumentData | ExpenseReportDocumentData | ExpenseEstimateDocumentData;
|
||||
let convertedData: ProposalDocumentData | ExpenseReportDocumentData | ExpenseEstimateDocumentData | DynamicDocumentData;
|
||||
|
||||
switch (docType) {
|
||||
// 전용 양식 (14종) 또는 동적 양식 → DynamicDocumentData
|
||||
const isDedicated = (DEDICATED_FORM_CODES as readonly string[]).includes(docTypeCode)
|
||||
&& docTypeCode !== 'proposal' && docTypeCode !== 'expenseReport' && docTypeCode !== 'expense_report'
|
||||
&& docTypeCode !== 'expenseEstimate' && docTypeCode !== 'expense_estimate';
|
||||
const isDynamic = !isDedicated && docTypeCode !== 'proposal' && docTypeCode !== 'expenseReport'
|
||||
&& docTypeCode !== 'expense_report' && docTypeCode !== 'expenseEstimate' && docTypeCode !== 'expense_estimate';
|
||||
|
||||
if (isDedicated || isDynamic) {
|
||||
// 전용 양식의 데이터를 content에서 추출
|
||||
const dedicatedDataMap: Record<string, unknown> = {
|
||||
officialDocument: formData.officialDocumentData,
|
||||
resignation: formData.resignationData,
|
||||
employmentCert: formData.employmentCertData,
|
||||
careerCert: formData.careerCertData,
|
||||
appointmentCert: formData.appointmentCertData,
|
||||
sealUsage: formData.sealUsageData,
|
||||
leaveNotice1st: formData.leaveNotice1stData,
|
||||
leaveNotice2nd: formData.leaveNotice2ndData,
|
||||
powerOfAttorney: formData.powerOfAttorneyData,
|
||||
boardMinutes: formData.boardMinutesData,
|
||||
quotation: formData.quotationData,
|
||||
};
|
||||
const dedicatedData = dedicatedDataMap[docTypeCode];
|
||||
const fields = dedicatedData
|
||||
? filterVisibleFields(dedicatedData as Record<string, unknown>)
|
||||
: (formData.dynamicFormData || {});
|
||||
|
||||
convertedData = {
|
||||
documentNo: formData.basicInfo.documentNo,
|
||||
createdAt: formData.basicInfo.draftDate,
|
||||
formName: formData.basicInfo.formName || getFormName(docTypeCode),
|
||||
fields,
|
||||
fieldLabels: getFieldLabels(docTypeCode),
|
||||
approvers,
|
||||
drafter,
|
||||
};
|
||||
setModalDocTypeOverride('dynamic');
|
||||
setModalData(convertedData);
|
||||
return;
|
||||
}
|
||||
|
||||
switch (docTypeCode) {
|
||||
case 'expenseEstimate':
|
||||
case 'expense_estimate':
|
||||
convertedData = {
|
||||
documentNo: formData.basicInfo.documentNo,
|
||||
createdAt: formData.basicInfo.draftDate,
|
||||
@@ -360,8 +401,10 @@ export function ApprovalBox() {
|
||||
approvers,
|
||||
drafter,
|
||||
};
|
||||
setModalDocTypeOverride('expenseEstimate');
|
||||
break;
|
||||
case 'expenseReport':
|
||||
case 'expense_report':
|
||||
convertedData = {
|
||||
documentNo: formData.basicInfo.documentNo,
|
||||
createdAt: formData.basicInfo.draftDate,
|
||||
@@ -380,6 +423,7 @@ export function ApprovalBox() {
|
||||
approvers,
|
||||
drafter,
|
||||
};
|
||||
setModalDocTypeOverride('expenseReport');
|
||||
break;
|
||||
default: {
|
||||
// 품의서
|
||||
@@ -399,6 +443,7 @@ export function ApprovalBox() {
|
||||
approvers,
|
||||
drafter,
|
||||
};
|
||||
setModalDocTypeOverride('proposal');
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -477,15 +522,21 @@ export function ApprovalBox() {
|
||||
color: 'blue',
|
||||
},
|
||||
{
|
||||
value: 'pending',
|
||||
label: APPROVAL_TAB_LABELS.pending,
|
||||
count: fixedStats.pending,
|
||||
value: 'requested',
|
||||
label: APPROVAL_TAB_LABELS.requested,
|
||||
count: fixedStats.requested,
|
||||
color: 'yellow',
|
||||
},
|
||||
{
|
||||
value: 'approved',
|
||||
label: APPROVAL_TAB_LABELS.approved,
|
||||
count: fixedStats.approved,
|
||||
value: 'scheduled',
|
||||
label: APPROVAL_TAB_LABELS.scheduled,
|
||||
count: fixedStats.scheduled,
|
||||
color: 'blue',
|
||||
},
|
||||
{
|
||||
value: 'completed',
|
||||
label: APPROVAL_TAB_LABELS.completed,
|
||||
count: fixedStats.completed,
|
||||
color: 'green',
|
||||
},
|
||||
{
|
||||
@@ -587,14 +638,20 @@ export function ApprovalBox() {
|
||||
iconColor: 'text-blue-500',
|
||||
},
|
||||
{
|
||||
label: '미결재',
|
||||
value: `${fixedStats.pending}건`,
|
||||
label: '결재 요청',
|
||||
value: `${fixedStats.requested}건`,
|
||||
icon: Clock,
|
||||
iconColor: 'text-yellow-500',
|
||||
},
|
||||
{
|
||||
label: '결재완료',
|
||||
value: `${fixedStats.approved}건`,
|
||||
label: '예정',
|
||||
value: `${fixedStats.scheduled}건`,
|
||||
icon: Clock,
|
||||
iconColor: 'text-blue-500',
|
||||
},
|
||||
{
|
||||
label: '처리 완료',
|
||||
value: `${fixedStats.completed}건`,
|
||||
icon: FileCheck,
|
||||
iconColor: 'text-green-500',
|
||||
},
|
||||
@@ -627,42 +684,6 @@ export function ApprovalBox() {
|
||||
</>
|
||||
) : null,
|
||||
|
||||
tableHeaderActions: (
|
||||
<div className="flex items-center gap-2">
|
||||
<Select
|
||||
value={filterOption}
|
||||
onValueChange={(value) => setFilterOption(value as FilterOption)}
|
||||
>
|
||||
<SelectTrigger className="min-w-[140px] w-auto">
|
||||
<SelectValue placeholder="필터 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{FILTER_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select
|
||||
value={sortOption}
|
||||
onValueChange={(value) => setSortOption(value as SortOption)}
|
||||
>
|
||||
<SelectTrigger className="min-w-[140px] w-auto">
|
||||
<SelectValue placeholder="정렬 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SORT_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
),
|
||||
|
||||
renderTableRow: (item, index, globalIndex, handlers) => {
|
||||
const { isSelected, onToggle } = handlers;
|
||||
|
||||
@@ -723,7 +744,7 @@ export function ApprovalBox() {
|
||||
</div>
|
||||
}
|
||||
actions={
|
||||
item.status === 'pending' && isSelected && canApprove ? (
|
||||
item.status === 'requested' && isSelected && canApprove ? (
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="default"
|
||||
@@ -809,9 +830,10 @@ export function ApprovalBox() {
|
||||
setIsModalOpen(open);
|
||||
if (!open) {
|
||||
setModalData(null);
|
||||
setModalDocTypeOverride(null);
|
||||
}
|
||||
}}
|
||||
documentType={getDocumentType(selectedDocument.approvalType)}
|
||||
documentType={modalDocTypeOverride || getDocumentType(selectedDocument.approvalType)}
|
||||
data={modalData}
|
||||
mode="inbox"
|
||||
onEdit={handleModalEdit}
|
||||
|
||||
@@ -4,12 +4,12 @@
|
||||
*/
|
||||
|
||||
// ===== 메인 탭 타입 =====
|
||||
export type ApprovalTabType = 'all' | 'pending' | 'approved' | 'rejected';
|
||||
export type ApprovalTabType = 'all' | 'requested' | 'scheduled' | 'completed' | 'rejected';
|
||||
|
||||
// 결재 상태
|
||||
export type ApprovalStatus = 'pending' | 'approved' | 'rejected';
|
||||
export type ApprovalStatus = 'requested' | 'scheduled' | 'completed' | 'rejected';
|
||||
|
||||
// 결재 유형 (문서 종류): 지출결의서, 품의서, 지출예상내역서, 문서결재
|
||||
// 결재 유형 (문서 종류): 지출결의서, 품의서, 비용견적서, 문서결재
|
||||
export type ApprovalType = 'expense_report' | 'proposal' | 'expense_estimate' | 'document';
|
||||
|
||||
// 필터 옵션
|
||||
@@ -19,7 +19,7 @@ export const FILTER_OPTIONS: { value: FilterOption; label: string }[] = [
|
||||
{ value: 'all', label: '전체' },
|
||||
{ value: 'expense_report', label: '지출결의서' },
|
||||
{ value: 'proposal', label: '품의서' },
|
||||
{ value: 'expense_estimate', label: '지출예상내역서' },
|
||||
{ value: 'expense_estimate', label: '비용견적서' },
|
||||
{ value: 'document', label: '문서 결재' },
|
||||
];
|
||||
|
||||
@@ -38,6 +38,8 @@ export interface ApprovalRecord {
|
||||
id: string;
|
||||
documentNo: string; // 문서번호
|
||||
approvalType: ApprovalType; // 결재유형 (휴가, 경비 등)
|
||||
formCode?: string; // 양식코드
|
||||
formName?: string; // 양식명
|
||||
documentStatus: string; // 문서상태
|
||||
title: string; // 제목
|
||||
draftDate: string; // 기안일
|
||||
@@ -63,15 +65,16 @@ export interface ApprovalFormData {
|
||||
|
||||
export const APPROVAL_TAB_LABELS: Record<ApprovalTabType, string> = {
|
||||
all: '전체결재',
|
||||
pending: '미결재',
|
||||
approved: '결재완료',
|
||||
rejected: '결재반려',
|
||||
requested: '결재 요청',
|
||||
scheduled: '예정',
|
||||
completed: '처리 완료',
|
||||
rejected: '반려',
|
||||
};
|
||||
|
||||
export const APPROVAL_TYPE_LABELS: Record<ApprovalType, string> = {
|
||||
expense_report: '지출결의서',
|
||||
proposal: '품의서',
|
||||
expense_estimate: '지출예상내역서',
|
||||
expense_estimate: '비용견적서',
|
||||
document: '문서 결재',
|
||||
};
|
||||
|
||||
@@ -83,13 +86,15 @@ export const APPROVAL_TYPE_COLORS: Record<ApprovalType, string> = {
|
||||
};
|
||||
|
||||
export const APPROVAL_STATUS_LABELS: Record<ApprovalStatus, string> = {
|
||||
pending: '대기',
|
||||
approved: '승인',
|
||||
requested: '결재 요청',
|
||||
scheduled: '예정',
|
||||
completed: '처리 완료',
|
||||
rejected: '반려',
|
||||
};
|
||||
|
||||
export const APPROVAL_STATUS_COLORS: Record<ApprovalStatus, string> = {
|
||||
pending: 'bg-yellow-100 text-yellow-800',
|
||||
approved: 'bg-green-100 text-green-800',
|
||||
requested: 'bg-yellow-100 text-yellow-800',
|
||||
scheduled: 'bg-blue-100 text-blue-800',
|
||||
completed: 'bg-green-100 text-green-800',
|
||||
rejected: 'bg-red-100 text-red-800',
|
||||
};
|
||||
131
src/components/approval/CompletedBox/actions.ts
Normal file
131
src/components/approval/CompletedBox/actions.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* 완료함 서버 액션
|
||||
*
|
||||
* API Endpoints:
|
||||
* - GET /api/v1/approvals/completed - 완료함 목록 조회
|
||||
* - GET /api/v1/approvals/completed/summary - 완료함 통계
|
||||
*/
|
||||
|
||||
'use server';
|
||||
|
||||
import { executeServerAction } from '@/lib/api/execute-server-action';
|
||||
import { buildApiUrl } from '@/lib/api/query-params';
|
||||
import type { PaginatedApiResponse } from '@/lib/api/types';
|
||||
import type { CompletedRecord, CompletedStatus, ApprovalType } from './types';
|
||||
|
||||
// ============================================
|
||||
// API 응답 타입 정의
|
||||
// ============================================
|
||||
|
||||
interface CompletedSummary {
|
||||
total: number;
|
||||
approved: number;
|
||||
rejected: number;
|
||||
}
|
||||
|
||||
interface CompletedApiData {
|
||||
id: number;
|
||||
document_number: string;
|
||||
title: string;
|
||||
status: string;
|
||||
form?: { id: number; name: string; code: string; category: string };
|
||||
drafter?: { id: number; name: string; position?: string; department?: { name: string } };
|
||||
steps?: Array<{
|
||||
id: number;
|
||||
step_order: number;
|
||||
step_type: string;
|
||||
approver_id: number;
|
||||
approver?: { id: number; name: string; position?: string; department?: { name: string } };
|
||||
status: string;
|
||||
processed_at?: string;
|
||||
}>;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 헬퍼 함수
|
||||
// ============================================
|
||||
|
||||
function mapCompletedStatus(apiStatus: string): CompletedStatus {
|
||||
if (apiStatus === 'rejected') return 'rejected';
|
||||
return 'approved';
|
||||
}
|
||||
|
||||
function mapApprovalType(formCategory?: string): ApprovalType {
|
||||
const typeMap: Record<string, ApprovalType> = {
|
||||
'expense_report': 'expense_report',
|
||||
'proposal': 'proposal',
|
||||
'expense_estimate': 'expense_estimate',
|
||||
'document': 'document',
|
||||
};
|
||||
return typeMap[formCategory || ''] || 'proposal';
|
||||
}
|
||||
|
||||
function transformApiToFrontend(data: CompletedApiData): CompletedRecord {
|
||||
// 마지막 처리자 찾기
|
||||
const lastProcessedStep = data.steps
|
||||
?.filter(s => s.processed_at)
|
||||
.sort((a, b) => (b.processed_at || '').localeCompare(a.processed_at || ''))
|
||||
[0];
|
||||
|
||||
return {
|
||||
id: String(data.id),
|
||||
documentNo: data.document_number,
|
||||
approvalType: mapApprovalType(data.form?.category),
|
||||
formCode: data.form?.code,
|
||||
formName: data.form?.name,
|
||||
title: data.title,
|
||||
draftDate: data.created_at.replace('T', ' ').substring(0, 16),
|
||||
drafter: data.drafter?.name || '',
|
||||
drafterDepartment: data.drafter?.department?.name || '',
|
||||
drafterPosition: data.drafter?.position || '',
|
||||
completedDate: lastProcessedStep?.processed_at?.replace('T', ' ').substring(0, 16) || '',
|
||||
completedBy: lastProcessedStep?.approver?.name || '',
|
||||
status: mapCompletedStatus(data.status),
|
||||
createdAt: data.created_at,
|
||||
updatedAt: data.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// API 함수
|
||||
// ============================================
|
||||
|
||||
export async function getCompleted(params?: {
|
||||
page?: number; per_page?: number; search?: string; status?: string;
|
||||
approval_type?: string; sort_by?: string; sort_dir?: 'asc' | 'desc';
|
||||
start_date?: string; end_date?: string;
|
||||
}): Promise<{ data: CompletedRecord[]; total: number; lastPage: number; __authError?: boolean }> {
|
||||
const result = await executeServerAction<PaginatedApiResponse<CompletedApiData>>({
|
||||
url: buildApiUrl('/api/v1/approvals/completed', {
|
||||
page: params?.page,
|
||||
per_page: params?.per_page,
|
||||
search: params?.search,
|
||||
status: params?.status && params.status !== 'all' ? params.status : undefined,
|
||||
approval_type: params?.approval_type !== 'all' ? params?.approval_type : undefined,
|
||||
sort_by: params?.sort_by,
|
||||
sort_dir: params?.sort_dir,
|
||||
start_date: params?.start_date,
|
||||
end_date: params?.end_date,
|
||||
}),
|
||||
errorMessage: '완료함 목록 조회에 실패했습니다.',
|
||||
});
|
||||
|
||||
if (result.__authError) return { data: [], total: 0, lastPage: 1, __authError: true };
|
||||
if (!result.success || !result.data?.data) return { data: [], total: 0, lastPage: 1 };
|
||||
|
||||
return {
|
||||
data: result.data.data.map(transformApiToFrontend),
|
||||
total: result.data.total,
|
||||
lastPage: result.data.last_page,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getCompletedSummary(): Promise<CompletedSummary | null> {
|
||||
const result = await executeServerAction<CompletedSummary>({
|
||||
url: buildApiUrl('/api/v1/approvals/completed/summary'),
|
||||
errorMessage: '완료함 통계 조회에 실패했습니다.',
|
||||
});
|
||||
return result.success ? result.data || null : null;
|
||||
}
|
||||
555
src/components/approval/CompletedBox/index.tsx
Normal file
555
src/components/approval/CompletedBox/index.tsx
Normal file
@@ -0,0 +1,555 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useMemo, useCallback, useEffect, useRef } from 'react';
|
||||
import { useDateRange } from '@/hooks';
|
||||
import {
|
||||
Files,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
ClipboardList,
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { getCompleted, getCompletedSummary } from './actions';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { TableRow, TableCell } from '@/components/ui/table';
|
||||
import {
|
||||
UniversalListPage,
|
||||
type UniversalListConfig,
|
||||
type StatCard,
|
||||
type TabOption,
|
||||
} from '@/components/templates/UniversalListPage';
|
||||
import { ListMobileCard, InfoField } from '@/components/organisms/MobileCard';
|
||||
import { DocumentDetailModalV2 as DocumentDetailModal } from '@/components/approval/DocumentDetail';
|
||||
import type { DocumentType, ProposalDocumentData, ExpenseReportDocumentData, ExpenseEstimateDocumentData, DynamicDocumentData } from '@/components/approval/DocumentDetail/types';
|
||||
import { getFieldLabels, filterVisibleFields, getFormName } from '@/components/approval/DocumentDetail/field-labels';
|
||||
import { getApprovalById } from '@/components/approval/DocumentCreate/actions';
|
||||
import type {
|
||||
CompletedTabType,
|
||||
CompletedRecord,
|
||||
SortOption,
|
||||
FilterOption,
|
||||
ApprovalType,
|
||||
} from './types';
|
||||
import {
|
||||
COMPLETED_TAB_LABELS,
|
||||
SORT_OPTIONS,
|
||||
FILTER_OPTIONS,
|
||||
APPROVAL_TYPE_LABELS,
|
||||
COMPLETED_STATUS_LABELS,
|
||||
COMPLETED_STATUS_COLORS,
|
||||
} from './types';
|
||||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||
|
||||
// ===== 통계 타입 =====
|
||||
interface CompletedSummary {
|
||||
all: number;
|
||||
approved: number;
|
||||
rejected: number;
|
||||
}
|
||||
|
||||
export function CompletedBox() {
|
||||
// ===== 상태 관리 =====
|
||||
const [activeTab, setActiveTab] = useState<CompletedTabType>('all');
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [filterOption, setFilterOption] = useState<FilterOption>('all');
|
||||
const [sortOption, setSortOption] = useState<SortOption>('latest');
|
||||
const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set());
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const itemsPerPage = 20;
|
||||
|
||||
// 날짜 범위 상태
|
||||
const { startDate, endDate, setStartDate, setEndDate } = useDateRange('currentYear');
|
||||
|
||||
// ===== 문서 상세 모달 상태 =====
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [isModalLoading, setIsModalLoading] = useState(false);
|
||||
const [selectedDocument, setSelectedDocument] = useState<CompletedRecord | null>(null);
|
||||
const [modalData, setModalData] = useState<ProposalDocumentData | ExpenseReportDocumentData | ExpenseEstimateDocumentData | DynamicDocumentData | null>(null);
|
||||
const [modalDocType, setModalDocType] = useState<DocumentType>('proposal');
|
||||
|
||||
// API 데이터
|
||||
const [data, setData] = useState<CompletedRecord[]>([]);
|
||||
const [totalCount, setTotalCount] = useState(0);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const isInitialLoadDone = useRef(false);
|
||||
|
||||
// 통계 데이터
|
||||
const [summary, setSummary] = useState<CompletedSummary | null>(null);
|
||||
|
||||
// ===== 데이터 로드 =====
|
||||
const loadData = useCallback(async () => {
|
||||
if (!isInitialLoadDone.current) {
|
||||
setIsLoading(true);
|
||||
}
|
||||
try {
|
||||
const sortConfig: { sort_by: string; sort_dir: 'asc' | 'desc' } = (() => {
|
||||
switch (sortOption) {
|
||||
case 'latest': return { sort_by: 'created_at', sort_dir: 'desc' };
|
||||
case 'oldest': return { sort_by: 'created_at', sort_dir: 'asc' };
|
||||
default: return { sort_by: 'created_at', sort_dir: 'desc' };
|
||||
}
|
||||
})();
|
||||
|
||||
const statusParam = activeTab === 'all' ? undefined : activeTab;
|
||||
|
||||
const result = await getCompleted({
|
||||
page: currentPage,
|
||||
per_page: itemsPerPage,
|
||||
search: searchQuery || undefined,
|
||||
status: statusParam,
|
||||
approval_type: filterOption !== 'all' ? filterOption : undefined,
|
||||
start_date: startDate || undefined,
|
||||
end_date: endDate || undefined,
|
||||
...sortConfig,
|
||||
});
|
||||
|
||||
setData(result.data);
|
||||
setTotalCount(result.total);
|
||||
setTotalPages(result.lastPage);
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('Failed to load completed:', error);
|
||||
toast.error('완료함 목록을 불러오는데 실패했습니다.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
isInitialLoadDone.current = true;
|
||||
}
|
||||
}, [currentPage, itemsPerPage, searchQuery, filterOption, sortOption, activeTab, startDate, endDate]);
|
||||
|
||||
// ===== 통계 로드 =====
|
||||
const loadSummary = useCallback(async () => {
|
||||
try {
|
||||
const result = await getCompletedSummary();
|
||||
if (result) {
|
||||
setSummary({
|
||||
all: result.total,
|
||||
approved: result.approved,
|
||||
rejected: result.rejected,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('Failed to load summary:', error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// ===== 초기 로드 =====
|
||||
useEffect(() => {
|
||||
loadSummary();
|
||||
|
||||
}, []);
|
||||
|
||||
// ===== 데이터 로드 =====
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
|
||||
}, [currentPage, searchQuery, filterOption, sortOption, activeTab, startDate, endDate]);
|
||||
|
||||
// ===== 검색어/필터/탭 변경 시 페이지 초기화 =====
|
||||
const prevSearchRef = useRef(searchQuery);
|
||||
const prevFilterRef = useRef(filterOption);
|
||||
const prevSortRef = useRef(sortOption);
|
||||
const prevTabRef = useRef(activeTab);
|
||||
|
||||
useEffect(() => {
|
||||
const searchChanged = prevSearchRef.current !== searchQuery;
|
||||
const filterChanged = prevFilterRef.current !== filterOption;
|
||||
const sortChanged = prevSortRef.current !== sortOption;
|
||||
const tabChanged = prevTabRef.current !== activeTab;
|
||||
|
||||
if (searchChanged || filterChanged || sortChanged || tabChanged) {
|
||||
if (currentPage !== 1) {
|
||||
setCurrentPage(1);
|
||||
}
|
||||
prevSearchRef.current = searchQuery;
|
||||
prevFilterRef.current = filterOption;
|
||||
prevSortRef.current = sortOption;
|
||||
prevTabRef.current = activeTab;
|
||||
}
|
||||
}, [searchQuery, filterOption, sortOption, activeTab, currentPage]);
|
||||
|
||||
// ===== 탭 변경 핸들러 =====
|
||||
const handleTabChange = useCallback((value: string) => {
|
||||
setActiveTab(value as CompletedTabType);
|
||||
setSelectedItems(new Set());
|
||||
setSearchQuery('');
|
||||
}, []);
|
||||
|
||||
// ===== 체크박스 핸들러 =====
|
||||
const toggleSelection = useCallback((id: string) => {
|
||||
setSelectedItems(prev => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(id)) newSet.delete(id);
|
||||
else newSet.add(id);
|
||||
return newSet;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const toggleSelectAll = useCallback(() => {
|
||||
if (selectedItems.size === data.length && data.length > 0) {
|
||||
setSelectedItems(new Set());
|
||||
} else {
|
||||
setSelectedItems(new Set(data.map(item => item.id)));
|
||||
}
|
||||
}, [selectedItems.size, data]);
|
||||
|
||||
// ===== 통계 데이터 =====
|
||||
const stats = useMemo(() => ({
|
||||
all: summary?.all ?? 0,
|
||||
approved: summary?.approved ?? 0,
|
||||
rejected: summary?.rejected ?? 0,
|
||||
}), [summary]);
|
||||
|
||||
// ===== 문서 클릭/상세 보기 핸들러 =====
|
||||
const handleDocumentClick = useCallback(async (item: CompletedRecord) => {
|
||||
setSelectedDocument(item);
|
||||
setIsModalLoading(true);
|
||||
setIsModalOpen(true);
|
||||
|
||||
try {
|
||||
const result = await getApprovalById(parseInt(item.id));
|
||||
if (result.success && result.data) {
|
||||
const formData = result.data;
|
||||
const docTypeCode = formData.basicInfo.documentType;
|
||||
|
||||
const drafter = {
|
||||
id: 'drafter-1',
|
||||
name: formData.basicInfo.drafter,
|
||||
position: formData.basicInfo.drafterPosition || '',
|
||||
department: formData.basicInfo.drafterDepartment || '',
|
||||
status: 'approved' as const,
|
||||
};
|
||||
const approvers = formData.approvalLine.map((person, index) => ({
|
||||
id: person.id,
|
||||
name: person.name,
|
||||
position: person.position,
|
||||
department: person.department,
|
||||
status: index === 0 ? ('approved' as const) : ('none' as const),
|
||||
}));
|
||||
|
||||
const isBuiltin = ['proposal', 'expenseReport', 'expense_report', 'expenseEstimate', 'expense_estimate'].includes(docTypeCode);
|
||||
|
||||
if (!isBuiltin) {
|
||||
const dedicatedDataMap: Record<string, unknown> = {
|
||||
officialDocument: formData.officialDocumentData,
|
||||
resignation: formData.resignationData,
|
||||
employmentCert: formData.employmentCertData,
|
||||
careerCert: formData.careerCertData,
|
||||
appointmentCert: formData.appointmentCertData,
|
||||
sealUsage: formData.sealUsageData,
|
||||
leaveNotice1st: formData.leaveNotice1stData,
|
||||
leaveNotice2nd: formData.leaveNotice2ndData,
|
||||
powerOfAttorney: formData.powerOfAttorneyData,
|
||||
boardMinutes: formData.boardMinutesData,
|
||||
quotation: formData.quotationData,
|
||||
};
|
||||
const dedicatedData = dedicatedDataMap[docTypeCode];
|
||||
const fields = dedicatedData
|
||||
? filterVisibleFields(dedicatedData as Record<string, unknown>)
|
||||
: (formData.dynamicFormData || {});
|
||||
|
||||
setModalDocType('dynamic');
|
||||
setModalData({
|
||||
documentNo: formData.basicInfo.documentNo,
|
||||
createdAt: formData.basicInfo.draftDate,
|
||||
formName: formData.basicInfo.formName || getFormName(docTypeCode),
|
||||
fields,
|
||||
fieldLabels: getFieldLabels(docTypeCode),
|
||||
approvers,
|
||||
drafter,
|
||||
});
|
||||
} else if (docTypeCode === 'expenseEstimate' || docTypeCode === 'expense_estimate') {
|
||||
setModalDocType('expenseEstimate');
|
||||
setModalData({
|
||||
documentNo: formData.basicInfo.documentNo,
|
||||
createdAt: formData.basicInfo.draftDate,
|
||||
items: formData.expenseEstimateData?.items.map(i => ({
|
||||
id: i.id, expectedPaymentDate: i.expectedPaymentDate, category: i.category,
|
||||
amount: i.amount, vendor: i.vendor, account: i.memo || '',
|
||||
})) || [],
|
||||
totalExpense: formData.expenseEstimateData?.totalExpense || 0,
|
||||
accountBalance: formData.expenseEstimateData?.accountBalance || 0,
|
||||
finalDifference: formData.expenseEstimateData?.finalDifference || 0,
|
||||
approvers, drafter,
|
||||
});
|
||||
} else if (docTypeCode === 'expenseReport' || docTypeCode === 'expense_report') {
|
||||
setModalDocType('expenseReport');
|
||||
setModalData({
|
||||
documentNo: formData.basicInfo.documentNo,
|
||||
createdAt: formData.basicInfo.draftDate,
|
||||
requestDate: formData.expenseReportData?.requestDate || '',
|
||||
paymentDate: formData.expenseReportData?.paymentDate || '',
|
||||
items: formData.expenseReportData?.items.map((i, idx) => ({
|
||||
id: i.id, no: idx + 1, description: i.description, amount: i.amount, note: i.note,
|
||||
})) || [],
|
||||
cardInfo: formData.expenseReportData?.cardId || '-',
|
||||
totalAmount: formData.expenseReportData?.totalAmount || 0,
|
||||
attachments: [], approvers, drafter,
|
||||
});
|
||||
} else {
|
||||
setModalDocType('proposal');
|
||||
const uploadedFileUrls = (formData.proposalData?.uploadedFiles || []).map(f =>
|
||||
`/api/proxy/files/${f.id}/download`
|
||||
);
|
||||
setModalData({
|
||||
documentNo: formData.basicInfo.documentNo,
|
||||
createdAt: formData.basicInfo.draftDate,
|
||||
vendor: formData.proposalData?.vendor || '-',
|
||||
vendorPaymentDate: formData.proposalData?.vendorPaymentDate || '',
|
||||
title: formData.proposalData?.title || item.title,
|
||||
description: formData.proposalData?.description || '-',
|
||||
reason: formData.proposalData?.reason || '-',
|
||||
estimatedCost: formData.proposalData?.estimatedCost || 0,
|
||||
attachments: uploadedFileUrls,
|
||||
approvers, drafter,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
toast.error(result.error || '문서 조회에 실패했습니다.');
|
||||
setIsModalOpen(false);
|
||||
}
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('Failed to load document:', error);
|
||||
toast.error('문서를 불러오는데 실패했습니다.');
|
||||
setIsModalOpen(false);
|
||||
} finally {
|
||||
setIsModalLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// ===== 통계 카드 =====
|
||||
const statCards: StatCard[] = useMemo(() => [
|
||||
{ label: '전체', value: `${stats.all}건`, icon: Files, iconColor: 'text-blue-500' },
|
||||
{ label: '승인', value: `${stats.approved}건`, icon: CheckCircle2, iconColor: 'text-green-500' },
|
||||
{ label: '반려', value: `${stats.rejected}건`, icon: XCircle, iconColor: 'text-red-500' },
|
||||
], [stats]);
|
||||
|
||||
// ===== 탭 옵션 =====
|
||||
const tabs: TabOption[] = useMemo(() => [
|
||||
{ value: 'all', label: COMPLETED_TAB_LABELS.all, count: stats.all, color: 'blue' },
|
||||
{ value: 'approved', label: COMPLETED_TAB_LABELS.approved, count: stats.approved, color: 'green' },
|
||||
{ value: 'rejected', label: COMPLETED_TAB_LABELS.rejected, count: stats.rejected, color: 'red' },
|
||||
], [stats]);
|
||||
|
||||
// ===== 테이블 컬럼 =====
|
||||
const tableColumns = useMemo(() => [
|
||||
{ key: 'no', label: '번호', className: 'w-[60px] text-center' },
|
||||
{ key: 'documentNo', label: '문서번호', copyable: true },
|
||||
{ key: 'approvalType', label: '문서유형', copyable: true },
|
||||
{ key: 'title', label: '제목', copyable: true },
|
||||
{ key: 'drafter', label: '기안자', copyable: true },
|
||||
{ key: 'draftDate', label: '기안일시', copyable: true },
|
||||
{ key: 'completedDate', label: '완료일시', copyable: true },
|
||||
{ key: 'completedBy', label: '최종처리자', copyable: true },
|
||||
{ key: 'status', label: '상태', className: 'text-center' },
|
||||
], []);
|
||||
|
||||
|
||||
// ===== UniversalListPage 설정 =====
|
||||
const completedBoxConfig: UniversalListConfig<CompletedRecord> = useMemo(() => ({
|
||||
title: '완료함',
|
||||
description: '결재가 완료된 문서를 확인합니다.',
|
||||
icon: ClipboardList,
|
||||
basePath: '/approval/completed',
|
||||
|
||||
idField: 'id',
|
||||
|
||||
actions: {
|
||||
getList: async () => ({
|
||||
success: true,
|
||||
data: data,
|
||||
totalCount: totalCount,
|
||||
totalPages: totalPages,
|
||||
}),
|
||||
},
|
||||
|
||||
columns: tableColumns,
|
||||
|
||||
tabs: tabs,
|
||||
defaultTab: activeTab,
|
||||
|
||||
computeStats: () => statCards,
|
||||
|
||||
hideSearch: true,
|
||||
searchValue: searchQuery,
|
||||
onSearchChange: setSearchQuery,
|
||||
|
||||
dateRangeSelector: {
|
||||
enabled: true,
|
||||
showPresets: false,
|
||||
startDate,
|
||||
endDate,
|
||||
onStartDateChange: setStartDate,
|
||||
onEndDateChange: setEndDate,
|
||||
},
|
||||
|
||||
searchPlaceholder: '제목, 기안자, 부서 검색...',
|
||||
searchFilter: (item: CompletedRecord, search: string) => {
|
||||
const s = search.toLowerCase();
|
||||
return (
|
||||
item.title?.toLowerCase().includes(s) ||
|
||||
item.drafter?.toLowerCase().includes(s) ||
|
||||
item.drafterDepartment?.toLowerCase().includes(s) ||
|
||||
false
|
||||
);
|
||||
},
|
||||
|
||||
itemsPerPage: itemsPerPage,
|
||||
|
||||
filterConfig: [
|
||||
{
|
||||
key: 'approvalType',
|
||||
label: '문서유형',
|
||||
type: 'single',
|
||||
options: FILTER_OPTIONS.filter((o) => o.value !== 'all'),
|
||||
},
|
||||
{
|
||||
key: 'sort',
|
||||
label: '정렬',
|
||||
type: 'single',
|
||||
options: SORT_OPTIONS,
|
||||
},
|
||||
],
|
||||
initialFilters: {
|
||||
approvalType: filterOption,
|
||||
sort: sortOption,
|
||||
},
|
||||
filterTitle: '완료함 필터',
|
||||
|
||||
renderTableRow: (item, index, globalIndex, handlers) => {
|
||||
const { isSelected, onToggle } = handlers;
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
key={item.id}
|
||||
className="hover:bg-muted/50 cursor-pointer"
|
||||
onClick={() => handleDocumentClick(item)}
|
||||
>
|
||||
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
onChange={() => onToggle()}
|
||||
className="h-4 w-4 rounded border-gray-300"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">{globalIndex}</TableCell>
|
||||
<TableCell className="font-mono text-sm">{item.documentNo}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline">{APPROVAL_TYPE_LABELS[item.approvalType]}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="font-medium max-w-[200px] truncate">{item.title}</TableCell>
|
||||
<TableCell>{item.drafter}</TableCell>
|
||||
<TableCell>{item.draftDate}</TableCell>
|
||||
<TableCell>{item.completedDate}</TableCell>
|
||||
<TableCell>{item.completedBy}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Badge className={COMPLETED_STATUS_COLORS[item.status]}>
|
||||
{COMPLETED_STATUS_LABELS[item.status]}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
},
|
||||
|
||||
renderMobileCard: (item, index, globalIndex, handlers) => {
|
||||
const { isSelected, onToggle } = handlers;
|
||||
|
||||
return (
|
||||
<ListMobileCard
|
||||
id={item.id}
|
||||
title={item.title}
|
||||
headerBadges={
|
||||
<div className="flex gap-1">
|
||||
<Badge variant="outline">{APPROVAL_TYPE_LABELS[item.approvalType]}</Badge>
|
||||
<Badge className={COMPLETED_STATUS_COLORS[item.status]}>
|
||||
{COMPLETED_STATUS_LABELS[item.status]}
|
||||
</Badge>
|
||||
</div>
|
||||
}
|
||||
isSelected={isSelected}
|
||||
onToggleSelection={onToggle}
|
||||
infoGrid={
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<InfoField label="문서번호" value={item.documentNo} />
|
||||
<InfoField label="기안자" value={item.drafter} />
|
||||
<InfoField label="부서" value={item.drafterDepartment} />
|
||||
<InfoField label="기안일시" value={item.draftDate} />
|
||||
<InfoField label="완료일시" value={item.completedDate || '-'} />
|
||||
<InfoField label="최종처리자" value={item.completedBy || '-'} />
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
},
|
||||
|
||||
renderDialogs: () => (
|
||||
<>
|
||||
{selectedDocument && modalData && (
|
||||
<DocumentDetailModal
|
||||
open={isModalOpen}
|
||||
onOpenChange={(open) => {
|
||||
setIsModalOpen(open);
|
||||
if (!open) setModalData(null);
|
||||
}}
|
||||
documentType={modalDocType}
|
||||
data={modalData}
|
||||
mode="completed"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
),
|
||||
}), [
|
||||
data,
|
||||
totalCount,
|
||||
totalPages,
|
||||
tableColumns,
|
||||
tabs,
|
||||
activeTab,
|
||||
statCards,
|
||||
startDate,
|
||||
endDate,
|
||||
handleDocumentClick,
|
||||
selectedDocument,
|
||||
isModalOpen,
|
||||
modalData,
|
||||
modalDocType,
|
||||
]);
|
||||
|
||||
// 모바일 필터 변경 핸들러
|
||||
const handleMobileFilterChange = useCallback((filters: Record<string, string | string[]>) => {
|
||||
if (filters.approvalType) {
|
||||
setFilterOption(filters.approvalType as FilterOption);
|
||||
}
|
||||
if (filters.sort) {
|
||||
setSortOption(filters.sort as SortOption);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<UniversalListPage<CompletedRecord>
|
||||
config={completedBoxConfig}
|
||||
initialData={data}
|
||||
initialTotalCount={totalCount}
|
||||
externalPagination={{
|
||||
currentPage,
|
||||
totalPages,
|
||||
totalItems: totalCount,
|
||||
itemsPerPage,
|
||||
onPageChange: setCurrentPage,
|
||||
}}
|
||||
externalSelection={{
|
||||
selectedItems,
|
||||
onToggleSelection: toggleSelection,
|
||||
onToggleSelectAll: toggleSelectAll,
|
||||
getItemId: (item) => item.id,
|
||||
}}
|
||||
onTabChange={handleTabChange}
|
||||
onSearchChange={setSearchQuery}
|
||||
onFilterChange={handleMobileFilterChange}
|
||||
externalIsLoading={isLoading}
|
||||
/>
|
||||
);
|
||||
}
|
||||
76
src/components/approval/CompletedBox/types.ts
Normal file
76
src/components/approval/CompletedBox/types.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* 완료함 타입 정의
|
||||
* 결재가 완료된 문서 (승인/반려/전결)
|
||||
*/
|
||||
|
||||
// ===== 메인 탭 타입 =====
|
||||
export type CompletedTabType = 'all' | 'approved' | 'rejected';
|
||||
|
||||
// 완료 유형
|
||||
export type CompletedStatus = 'approved' | 'rejected';
|
||||
|
||||
// 결재 유형 (문서 종류)
|
||||
export type ApprovalType = 'expense_report' | 'proposal' | 'expense_estimate' | 'document';
|
||||
|
||||
// 필터 옵션
|
||||
export type FilterOption = 'all' | 'expense_report' | 'proposal' | 'expense_estimate' | 'document';
|
||||
|
||||
export const FILTER_OPTIONS: { value: FilterOption; label: string }[] = [
|
||||
{ value: 'all', label: '전체' },
|
||||
{ value: 'expense_report', label: '지출결의서' },
|
||||
{ value: 'proposal', label: '품의서' },
|
||||
{ value: 'expense_estimate', label: '비용견적서' },
|
||||
{ value: 'document', label: '문서 결재' },
|
||||
];
|
||||
|
||||
// 정렬 옵션
|
||||
export type SortOption = 'latest' | 'oldest';
|
||||
|
||||
export const SORT_OPTIONS: { value: SortOption; label: string }[] = [
|
||||
{ value: 'latest', label: '최신순' },
|
||||
{ value: 'oldest', label: '오래된순' },
|
||||
];
|
||||
|
||||
// ===== 완료 문서 레코드 =====
|
||||
export interface CompletedRecord {
|
||||
id: string;
|
||||
documentNo: string;
|
||||
approvalType: ApprovalType;
|
||||
formCode?: string; // 양식코드
|
||||
formName?: string; // 양식명
|
||||
title: string;
|
||||
draftDate: string;
|
||||
drafter: string;
|
||||
drafterDepartment: string;
|
||||
drafterPosition: string;
|
||||
completedDate: string; // 완료일시
|
||||
completedBy: string; // 최종 처리자
|
||||
status: CompletedStatus;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
// ===== 상수 정의 =====
|
||||
|
||||
export const COMPLETED_TAB_LABELS: Record<CompletedTabType, string> = {
|
||||
all: '전체',
|
||||
approved: '승인',
|
||||
rejected: '반려',
|
||||
};
|
||||
|
||||
export const APPROVAL_TYPE_LABELS: Record<ApprovalType, string> = {
|
||||
expense_report: '지출결의서',
|
||||
proposal: '품의서',
|
||||
expense_estimate: '비용견적서',
|
||||
document: '문서 결재',
|
||||
};
|
||||
|
||||
export const COMPLETED_STATUS_LABELS: Record<CompletedStatus, string> = {
|
||||
approved: '승인',
|
||||
rejected: '반려',
|
||||
};
|
||||
|
||||
export const COMPLETED_STATUS_COLORS: Record<CompletedStatus, string> = {
|
||||
approved: 'bg-green-100 text-green-800',
|
||||
rejected: 'bg-red-100 text-red-800',
|
||||
};
|
||||
150
src/components/approval/DocumentCreate/AppointmentCertForm.tsx
Normal file
150
src/components/approval/DocumentCreate/AppointmentCertForm.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { DatePicker } from '@/components/ui/date-picker';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { format } from 'date-fns';
|
||||
import { getEmployeeOptions, getEmployeeAutoFill } from './form-actions';
|
||||
import type { AppointmentCertData } from './types';
|
||||
|
||||
interface AppointmentCertFormProps {
|
||||
data: AppointmentCertData;
|
||||
onChange: (data: AppointmentCertData) => void;
|
||||
}
|
||||
|
||||
export function AppointmentCertForm({ data, onChange }: AppointmentCertFormProps) {
|
||||
const [employees, setEmployees] = useState<{ id: string; name: string; department: string }[]>([]);
|
||||
const [isLoadingEmployees, setIsLoadingEmployees] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
async function loadEmployees() {
|
||||
setIsLoadingEmployees(true);
|
||||
const result = await getEmployeeOptions();
|
||||
if (result.success) setEmployees(result.data);
|
||||
setIsLoadingEmployees(false);
|
||||
}
|
||||
loadEmployees();
|
||||
}, []);
|
||||
|
||||
const handleEmployeeSelect = async (employeeId: string) => {
|
||||
onChange({ ...data, employeeId });
|
||||
const result = await getEmployeeAutoFill(employeeId);
|
||||
if (result.success && result.data) {
|
||||
const e = result.data;
|
||||
onChange({
|
||||
...data,
|
||||
employeeId,
|
||||
employeeName: e.name,
|
||||
residentNumber: e.residentNumber,
|
||||
department: e.department,
|
||||
phone: e.phone,
|
||||
issueDate: data.issueDate || format(new Date(), 'yyyy-MM-dd'),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 1. 인적 사항 */}
|
||||
<div className="bg-white rounded-lg border p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">인적 사항</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>대상사원 <span className="text-red-500">*</span></Label>
|
||||
<Select
|
||||
value={data.employeeId || ''}
|
||||
onValueChange={handleEmployeeSelect}
|
||||
disabled={isLoadingEmployees}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={isLoadingEmployees ? '불러오는 중...' : '사원을 선택해주세요'} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{employees.map((emp) => (
|
||||
<SelectItem key={emp.id} value={emp.id}>
|
||||
{emp.name} ({emp.department})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>성명</Label>
|
||||
<Input value={data.employeeName} disabled className="bg-gray-50" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>주민등록번호</Label>
|
||||
<Input value={data.residentNumber} disabled className="bg-gray-50" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>소속</Label>
|
||||
<Input value={data.department} disabled className="bg-gray-50" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>연락처</Label>
|
||||
<Input value={data.phone} disabled className="bg-gray-50" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 2. 위촉 정보 */}
|
||||
<div className="bg-white rounded-lg border p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">위촉 정보</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>기간 시작</Label>
|
||||
<DatePicker
|
||||
value={data.appointmentPeriodStart}
|
||||
onChange={(date) => onChange({ ...data, appointmentPeriodStart: date })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>기간 종료</Label>
|
||||
<DatePicker
|
||||
value={data.appointmentPeriodEnd}
|
||||
onChange={(date) => onChange({ ...data, appointmentPeriodEnd: date })}
|
||||
/>
|
||||
</div>
|
||||
<div className="md:col-span-2 space-y-2">
|
||||
<Label>계약 자격</Label>
|
||||
<Input
|
||||
placeholder="계약 자격을 입력해주세요"
|
||||
value={data.contractQualification}
|
||||
onChange={(e) => onChange({ ...data, contractQualification: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 3. 발급 정보 */}
|
||||
<div className="bg-white rounded-lg border p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">발급 정보</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>용도 <span className="text-red-500">*</span></Label>
|
||||
<Input
|
||||
placeholder="사용 용도를 입력해주세요"
|
||||
value={data.purpose}
|
||||
onChange={(e) => onChange({ ...data, purpose: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>발급일</Label>
|
||||
<DatePicker
|
||||
value={data.issueDate}
|
||||
onChange={(date) => onChange({ ...data, issueDate: date })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import type { ApprovalPerson } from './types';
|
||||
import type { ApprovalPerson, StepType } from './types';
|
||||
import { getEmployees } from './actions';
|
||||
|
||||
interface ApprovalLineSectionProps {
|
||||
@@ -18,6 +18,11 @@ interface ApprovalLineSectionProps {
|
||||
onChange: (data: ApprovalPerson[]) => void;
|
||||
}
|
||||
|
||||
const STEP_TYPE_OPTIONS: { value: StepType; label: string }[] = [
|
||||
{ value: 'approval', label: '결재' },
|
||||
{ value: 'agreement', label: '합의' },
|
||||
];
|
||||
|
||||
export function ApprovalLineSection({ data, onChange }: ApprovalLineSectionProps) {
|
||||
const [employees, setEmployees] = useState<ApprovalPerson[]>([]);
|
||||
|
||||
@@ -32,6 +37,7 @@ export function ApprovalLineSection({ data, onChange }: ApprovalLineSectionProps
|
||||
department: '',
|
||||
position: '',
|
||||
name: '',
|
||||
stepType: 'approval',
|
||||
};
|
||||
onChange([...data, newPerson]);
|
||||
};
|
||||
@@ -44,11 +50,17 @@ export function ApprovalLineSection({ data, onChange }: ApprovalLineSectionProps
|
||||
const employee = employees.find((e) => e.id === employeeId);
|
||||
if (employee) {
|
||||
const newData = [...data];
|
||||
newData[index] = { ...employee };
|
||||
newData[index] = { ...employee, stepType: newData[index].stepType || 'approval' };
|
||||
onChange(newData);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStepTypeChange = (index: number, stepType: StepType) => {
|
||||
const newData = [...data];
|
||||
newData[index] = { ...newData[index], stepType };
|
||||
onChange(newData);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg border p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
@@ -60,7 +72,12 @@ export function ApprovalLineSection({ data, onChange }: ApprovalLineSectionProps
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="text-sm text-gray-500 mb-2">부서 / 직책 / 이름</div>
|
||||
<div className="grid grid-cols-[2rem_5rem_1fr_2.25rem] gap-2 text-sm text-gray-500 mb-2">
|
||||
<span className="text-center">순</span>
|
||||
<span>유형</span>
|
||||
<span>부서 / 직책 / 이름</span>
|
||||
<span />
|
||||
</div>
|
||||
|
||||
{data.length === 0 ? (
|
||||
<div className="text-center py-4 text-gray-400">
|
||||
@@ -68,13 +85,32 @@ export function ApprovalLineSection({ data, onChange }: ApprovalLineSectionProps
|
||||
</div>
|
||||
) : (
|
||||
data.map((person, index) => (
|
||||
<div key={`${person.id}-${index}`} className="flex items-center gap-2">
|
||||
<span className="w-8 text-center text-sm text-gray-500">{index + 1}</span>
|
||||
<div key={`${person.id}-${index}`} className="grid grid-cols-[2rem_5rem_1fr_2.25rem] gap-2 items-center">
|
||||
<span className="text-center text-sm text-gray-500">{index + 1}</span>
|
||||
|
||||
{/* 결재/합의 선택 */}
|
||||
<Select
|
||||
value={person.stepType || 'approval'}
|
||||
onValueChange={(value) => handleStepTypeChange(index, value as StepType)}
|
||||
>
|
||||
<SelectTrigger className="h-9 text-xs px-2">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{STEP_TYPE_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* 직원 선택 */}
|
||||
<Select
|
||||
value={person.id.startsWith('temp-') ? undefined : person.id}
|
||||
onValueChange={(value) => handleChange(index, value)}
|
||||
>
|
||||
<SelectTrigger className="flex-1">
|
||||
<SelectTrigger>
|
||||
<SelectValue
|
||||
placeholder={
|
||||
person.name && !person.id.startsWith('temp-')
|
||||
@@ -91,6 +127,7 @@ export function ApprovalLineSection({ data, onChange }: ApprovalLineSectionProps
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
@@ -105,4 +142,4 @@ export function ApprovalLineSection({ data, onChange }: ApprovalLineSectionProps
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,28 +1,74 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback } from 'react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import type { BasicInfo, DocumentType } from './types';
|
||||
import { DOCUMENT_TYPE_OPTIONS } from './types';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { FormSelector } from './FormSelector';
|
||||
import type { BasicInfo } from './types';
|
||||
|
||||
interface BasicInfoSectionProps {
|
||||
data: BasicInfo;
|
||||
onChange: (data: BasicInfo) => void;
|
||||
}
|
||||
|
||||
// 양식 코드 → documentType 매핑 (전용 폼 호환)
|
||||
const FORM_CODE_TO_DOC_TYPE: Record<string, string> = {
|
||||
// 기존 3종
|
||||
'proposal': 'proposal',
|
||||
'expense_report': 'expenseReport',
|
||||
'expenseReport': 'expenseReport',
|
||||
'expense_estimate': 'expenseEstimate',
|
||||
'expenseEstimate': 'expenseEstimate',
|
||||
// 일반
|
||||
'official_letter': 'officialDocument',
|
||||
// 인사/근태
|
||||
'resignation': 'resignation',
|
||||
'leave_promotion_1st': 'leaveNotice1st',
|
||||
'leave_promotion_2nd': 'leaveNotice2nd',
|
||||
'delegation': 'powerOfAttorney',
|
||||
'board_minutes': 'boardMinutes',
|
||||
// 증명서
|
||||
'employment_cert': 'employmentCert',
|
||||
'career_cert': 'careerCert',
|
||||
'appointment_cert': 'appointmentCert',
|
||||
'seal_usage': 'sealUsage',
|
||||
// 재무
|
||||
'quotation': 'quotation',
|
||||
};
|
||||
|
||||
export function BasicInfoSection({ data, onChange }: BasicInfoSectionProps) {
|
||||
const handleFormSelect = useCallback((form: { id: number; code: string; name: string; category: string } | null) => {
|
||||
if (!form) {
|
||||
onChange({
|
||||
...data,
|
||||
formId: undefined,
|
||||
formCode: undefined,
|
||||
formName: undefined,
|
||||
formCategory: undefined,
|
||||
documentType: 'proposal', // 기본값
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 기존 전용 폼 코드면 documentType으로 매핑
|
||||
const docType = FORM_CODE_TO_DOC_TYPE[form.code] || form.code;
|
||||
|
||||
onChange({
|
||||
...data,
|
||||
formId: form.id,
|
||||
formCode: form.code,
|
||||
formName: form.name,
|
||||
formCategory: form.category,
|
||||
documentType: docType,
|
||||
});
|
||||
}, [data, onChange]);
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg border p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">기본 정보</h3>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
|
||||
{/* 기안자 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="drafter">기안자</Label>
|
||||
@@ -50,32 +96,30 @@ export function BasicInfoSection({ data, onChange }: BasicInfoSectionProps) {
|
||||
<Label htmlFor="documentNo">문서번호</Label>
|
||||
<Input
|
||||
id="documentNo"
|
||||
placeholder="문서번호를 입력해주세요"
|
||||
placeholder="자동 생성"
|
||||
value={data.documentNo}
|
||||
onChange={(e) => onChange({ ...data, documentNo: e.target.value })}
|
||||
disabled
|
||||
className="bg-gray-50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 문서유형 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="documentType">문서유형</Label>
|
||||
<Select
|
||||
value={data.documentType}
|
||||
onValueChange={(value) => onChange({ ...data, documentType: value as DocumentType })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="문서유형 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{DOCUMENT_TYPE_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{/* 긴급 여부 */}
|
||||
<div className="flex items-end space-x-2 pb-2">
|
||||
<Checkbox
|
||||
id="isUrgent"
|
||||
checked={data.isUrgent || false}
|
||||
onCheckedChange={(checked) => onChange({ ...data, isUrgent: checked === true })}
|
||||
/>
|
||||
<Label htmlFor="isUrgent" className="cursor-pointer">긴급</Label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 2단계 양식 선택 */}
|
||||
<FormSelector
|
||||
selectedFormId={data.formId}
|
||||
selectedFormCode={data.formCode}
|
||||
onFormSelect={handleFormSelect}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
175
src/components/approval/DocumentCreate/BoardMinutesForm.tsx
Normal file
175
src/components/approval/DocumentCreate/BoardMinutesForm.tsx
Normal file
@@ -0,0 +1,175 @@
|
||||
'use client';
|
||||
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { DatePicker } from '@/components/ui/date-picker';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import type { BoardMinutesData } from './types';
|
||||
|
||||
interface BoardMinutesFormProps {
|
||||
data: BoardMinutesData;
|
||||
onChange: (data: BoardMinutesData) => void;
|
||||
}
|
||||
|
||||
const RESULT_OPTIONS = [
|
||||
{ value: 'approved', label: '가결' },
|
||||
{ value: 'rejected', label: '부결' },
|
||||
{ value: 'deferred', label: '보류' },
|
||||
];
|
||||
|
||||
export function BoardMinutesForm({ data, onChange }: BoardMinutesFormProps) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 1. 일시/장소 */}
|
||||
<div className="bg-white rounded-lg border p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">일시 / 장소</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>일시 <span className="text-red-500">*</span></Label>
|
||||
<DatePicker
|
||||
value={data.meetingDate}
|
||||
onChange={(date) => onChange({ ...data, meetingDate: date })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>장소 <span className="text-red-500">*</span></Label>
|
||||
<Input
|
||||
placeholder="장소를 입력해주세요"
|
||||
value={data.meetingPlace}
|
||||
onChange={(e) => onChange({ ...data, meetingPlace: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 2. 출석 이사 / 감사 */}
|
||||
<div className="bg-white rounded-lg border p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">출석 이사 / 감사</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>이사 총수 <span className="text-red-500">*</span></Label>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="이사 총수"
|
||||
value={data.totalDirectors || ''}
|
||||
onChange={(e) => onChange({ ...data, totalDirectors: Number(e.target.value) })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>출석 이사 <span className="text-red-500">*</span></Label>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="출석 이사 수"
|
||||
value={data.attendingDirectors || ''}
|
||||
onChange={(e) => onChange({ ...data, attendingDirectors: Number(e.target.value) })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>감사 총수</Label>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="감사 총수"
|
||||
value={data.totalAuditors || ''}
|
||||
onChange={(e) => onChange({ ...data, totalAuditors: Number(e.target.value) })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>출석 감사</Label>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="출석 감사 수"
|
||||
value={data.attendingAuditors || ''}
|
||||
onChange={(e) => onChange({ ...data, attendingAuditors: Number(e.target.value) })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 3. 의안 */}
|
||||
<div className="bg-white rounded-lg border p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">의안</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>제목 <span className="text-red-500">*</span></Label>
|
||||
<Input
|
||||
placeholder="의안 제목을 입력해주세요"
|
||||
value={data.agendaTitle}
|
||||
onChange={(e) => onChange({ ...data, agendaTitle: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>결과</Label>
|
||||
<Select
|
||||
value={data.agendaResult || ''}
|
||||
onValueChange={(value) => onChange({ ...data, agendaResult: value })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="결과를 선택해주세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{RESULT_OPTIONS.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 4. 의사경과 */}
|
||||
<div className="bg-white rounded-lg border p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">의사경과</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>의장 <span className="text-red-500">*</span></Label>
|
||||
<Input
|
||||
placeholder="의장명을 입력해주세요"
|
||||
value={data.chairperson}
|
||||
onChange={(e) => onChange({ ...data, chairperson: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>내용</Label>
|
||||
<Textarea
|
||||
placeholder="의사경과 내용을 입력해주세요"
|
||||
value={data.proceedings}
|
||||
onChange={(e) => onChange({ ...data, proceedings: e.target.value })}
|
||||
className="min-h-[150px]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 5. 폐회 */}
|
||||
<div className="bg-white rounded-lg border p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">폐회</h3>
|
||||
<div className="space-y-2">
|
||||
<Label>폐회 시각</Label>
|
||||
<Input
|
||||
placeholder="예: 16:00"
|
||||
value={data.adjournmentTime}
|
||||
onChange={(e) => onChange({ ...data, adjournmentTime: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 6. 기명날인 */}
|
||||
<div className="bg-white rounded-lg border p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">기명날인</h3>
|
||||
<Textarea
|
||||
placeholder="기명날인 대상자를 입력해주세요 (예: 의장 홍길동, 이사 김영희)"
|
||||
value={data.signatories}
|
||||
onChange={(e) => onChange({ ...data, signatories: e.target.value })}
|
||||
className="min-h-[80px]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
197
src/components/approval/DocumentCreate/CareerCertForm.tsx
Normal file
197
src/components/approval/DocumentCreate/CareerCertForm.tsx
Normal file
@@ -0,0 +1,197 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { DatePicker } from '@/components/ui/date-picker';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { format } from 'date-fns';
|
||||
import { getEmployeeOptions, getEmployeeAutoFill, getCompanyAutoFill } from './form-actions';
|
||||
import type { CareerCertData } from './types';
|
||||
|
||||
interface CareerCertFormProps {
|
||||
data: CareerCertData;
|
||||
onChange: (data: CareerCertData) => void;
|
||||
}
|
||||
|
||||
export function CareerCertForm({ data, onChange }: CareerCertFormProps) {
|
||||
const [employees, setEmployees] = useState<{ id: string; name: string; department: string }[]>([]);
|
||||
const [isLoadingEmployees, setIsLoadingEmployees] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
setIsLoadingEmployees(true);
|
||||
const [empResult, companyResult] = await Promise.all([
|
||||
getEmployeeOptions(),
|
||||
!data.companyName ? getCompanyAutoFill() : Promise.resolve(null),
|
||||
]);
|
||||
if (empResult.success) setEmployees(empResult.data);
|
||||
|
||||
if (companyResult?.success && companyResult.data) {
|
||||
const c = companyResult.data;
|
||||
onChange({
|
||||
...data,
|
||||
companyName: data.companyName || c.companyName,
|
||||
businessNumber: data.businessNumber || c.businessNumber,
|
||||
representativeName: data.representativeName || c.representativeName,
|
||||
companyPhone: data.companyPhone || c.phone,
|
||||
companyAddress: data.companyAddress || c.address,
|
||||
issueDate: data.issueDate || format(new Date(), 'yyyy-MM-dd'),
|
||||
});
|
||||
}
|
||||
setIsLoadingEmployees(false);
|
||||
}
|
||||
load();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const handleEmployeeSelect = async (employeeId: string) => {
|
||||
onChange({ ...data, employeeId });
|
||||
const result = await getEmployeeAutoFill(employeeId);
|
||||
if (result.success && result.data) {
|
||||
const e = result.data;
|
||||
onChange({
|
||||
...data,
|
||||
employeeId,
|
||||
employeeName: e.name,
|
||||
residentNumber: e.residentNumber,
|
||||
birthDate: e.birthDate,
|
||||
employeeAddress: e.address,
|
||||
department: e.department,
|
||||
positionTitle: e.position,
|
||||
workPeriodStart: e.joinDate,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 1. 인적 사항 */}
|
||||
<div className="bg-white rounded-lg border p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">인적 사항</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>대상사원 <span className="text-red-500">*</span></Label>
|
||||
<Select
|
||||
value={data.employeeId || ''}
|
||||
onValueChange={handleEmployeeSelect}
|
||||
disabled={isLoadingEmployees}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={isLoadingEmployees ? '불러오는 중...' : '사원을 선택해주세요'} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{employees.map((emp) => (
|
||||
<SelectItem key={emp.id} value={emp.id}>
|
||||
{emp.name} ({emp.department})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>성명</Label>
|
||||
<Input value={data.employeeName} disabled className="bg-gray-50" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>주민등록번호</Label>
|
||||
<Input value={data.residentNumber} disabled className="bg-gray-50" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>생년월일</Label>
|
||||
<Input value={data.birthDate} disabled className="bg-gray-50" />
|
||||
</div>
|
||||
<div className="md:col-span-2 space-y-2">
|
||||
<Label>주소</Label>
|
||||
<Input value={data.employeeAddress} disabled className="bg-gray-50" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 2. 경력 사항 */}
|
||||
<div className="bg-white rounded-lg border p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">경력 사항</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>회사명</Label>
|
||||
<Input value={data.companyName} disabled className="bg-gray-50" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>사업자번호</Label>
|
||||
<Input value={data.businessNumber} disabled className="bg-gray-50" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>대표자</Label>
|
||||
<Input value={data.representativeName} disabled className="bg-gray-50" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>대표전화</Label>
|
||||
<Input value={data.companyPhone} disabled className="bg-gray-50" />
|
||||
</div>
|
||||
<div className="md:col-span-2 space-y-2">
|
||||
<Label>소재지</Label>
|
||||
<Input value={data.companyAddress} disabled className="bg-gray-50" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>소속부서</Label>
|
||||
<Input value={data.department} disabled className="bg-gray-50" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>직위/직급</Label>
|
||||
<Input value={data.positionTitle} disabled className="bg-gray-50" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>근무기간 시작</Label>
|
||||
<DatePicker
|
||||
value={data.workPeriodStart}
|
||||
onChange={(date) => onChange({ ...data, workPeriodStart: date })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>근무기간 종료</Label>
|
||||
<DatePicker
|
||||
value={data.workPeriodEnd}
|
||||
onChange={(date) => onChange({ ...data, workPeriodEnd: date })}
|
||||
/>
|
||||
</div>
|
||||
<div className="md:col-span-2 space-y-2">
|
||||
<Label>담당업무</Label>
|
||||
<Input
|
||||
placeholder="담당업무를 입력해주세요"
|
||||
value={data.duties}
|
||||
onChange={(e) => onChange({ ...data, duties: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 3. 발급 정보 */}
|
||||
<div className="bg-white rounded-lg border p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">발급 정보</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>사용 용도 <span className="text-red-500">*</span></Label>
|
||||
<Input
|
||||
placeholder="사용 용도를 입력해주세요"
|
||||
value={data.purpose}
|
||||
onChange={(e) => onChange({ ...data, purpose: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>발급일</Label>
|
||||
<DatePicker
|
||||
value={data.issueDate}
|
||||
onChange={(date) => onChange({ ...data, issueDate: date })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
373
src/components/approval/DocumentCreate/DynamicFormRenderer.tsx
Normal file
373
src/components/approval/DocumentCreate/DynamicFormRenderer.tsx
Normal file
@@ -0,0 +1,373 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { DatePicker } from '@/components/ui/date-picker';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Plus, Trash2 } from 'lucide-react';
|
||||
import { getApprovalFormDetail } from './form-actions';
|
||||
import type { ApprovalFormField } from './form-actions';
|
||||
|
||||
interface DynamicFormRendererProps {
|
||||
formId: number;
|
||||
data: Record<string, unknown>;
|
||||
onChange: (data: Record<string, unknown>) => void;
|
||||
onFieldsLoaded?: (fields: ApprovalFormField[]) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 동적 폼 렌더러
|
||||
* API template.fields 기반으로 폼 필드를 자동 생성
|
||||
*/
|
||||
export function DynamicFormRenderer({ formId, data, onChange, onFieldsLoaded }: DynamicFormRendererProps) {
|
||||
const [fields, setFields] = useState<ApprovalFormField[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
const result = await getApprovalFormDetail(formId);
|
||||
if (result.success && result.data) {
|
||||
const loadedFields = result.data.template.fields || [];
|
||||
setFields(loadedFields);
|
||||
onFieldsLoaded?.(loadedFields);
|
||||
} else {
|
||||
setError(result.error || '양식 정보를 불러올 수 없습니다.');
|
||||
}
|
||||
setIsLoading(false);
|
||||
};
|
||||
load();
|
||||
}, [formId]);
|
||||
|
||||
const handleFieldChange = useCallback((name: string, value: unknown) => {
|
||||
onChange({ ...data, [name]: value });
|
||||
}, [data, onChange]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="bg-white rounded-lg border p-6">
|
||||
<div className="animate-pulse space-y-4">
|
||||
<div className="h-6 bg-gray-200 rounded w-32" />
|
||||
<div className="h-10 bg-gray-200 rounded" />
|
||||
<div className="h-10 bg-gray-200 rounded" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="bg-white rounded-lg border p-6">
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (fields.length === 0) {
|
||||
return (
|
||||
<div className="bg-white rounded-lg border p-6">
|
||||
<p className="text-sm text-muted-foreground">이 양식에는 입력 필드가 없습니다.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg border p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">문서 내용</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{fields.map((field) => (
|
||||
<DynamicField
|
||||
key={field.name}
|
||||
field={field}
|
||||
value={data[field.name]}
|
||||
onChange={(value) => handleFieldChange(field.name, value)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ===== 개별 필드 렌더러 =====
|
||||
|
||||
interface DynamicFieldProps {
|
||||
field: ApprovalFormField;
|
||||
value: unknown;
|
||||
onChange: (value: unknown) => void;
|
||||
}
|
||||
|
||||
function DynamicField({ field, value, onChange }: DynamicFieldProps) {
|
||||
const isFullWidth = field.type === 'textarea' || field.type === 'array' || field.type === 'daterange';
|
||||
|
||||
return (
|
||||
<div className={`space-y-2 ${isFullWidth ? 'md:col-span-2' : ''}`}>
|
||||
<Label>
|
||||
{field.label}
|
||||
{field.required && <span className="text-destructive ml-1">*</span>}
|
||||
</Label>
|
||||
{renderField(field, value, onChange)}
|
||||
{field.description && (
|
||||
<p className="text-xs text-muted-foreground">{field.description}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function renderField(field: ApprovalFormField, value: unknown, onChange: (value: unknown) => void) {
|
||||
switch (field.type) {
|
||||
case 'text':
|
||||
return (
|
||||
<Input
|
||||
value={(value as string) || ''}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={field.placeholder || `${field.label} 입력`}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'number':
|
||||
return (
|
||||
<Input
|
||||
type="number"
|
||||
value={(value as number) ?? ''}
|
||||
onChange={(e) => onChange(e.target.value ? Number(e.target.value) : '')}
|
||||
placeholder={field.placeholder || '0'}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'textarea':
|
||||
return (
|
||||
<Textarea
|
||||
value={(value as string) || ''}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={field.placeholder || `${field.label} 입력`}
|
||||
rows={4}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'date':
|
||||
return (
|
||||
<DatePicker
|
||||
value={(value as string) || ''}
|
||||
onChange={(date) => onChange(date)}
|
||||
placeholder={field.placeholder || '날짜 선택'}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'daterange':
|
||||
return <DateRangeField value={value as { start?: string; end?: string }} onChange={onChange} field={field} />;
|
||||
|
||||
case 'select':
|
||||
return (
|
||||
<Select
|
||||
key={`${field.name}-${(value as string) || 'empty'}`}
|
||||
value={(value as string) || undefined}
|
||||
onValueChange={onChange}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={field.placeholder || '선택'} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(field.options || []).map((option) => (
|
||||
<SelectItem key={option} value={option}>
|
||||
{option}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
|
||||
case 'checkbox':
|
||||
return (
|
||||
<div className="flex items-center space-x-2 pt-1">
|
||||
<Checkbox
|
||||
checked={(value as boolean) || false}
|
||||
onCheckedChange={(checked) => onChange(checked === true)}
|
||||
/>
|
||||
<span className="text-sm">{field.placeholder || field.label}</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'file':
|
||||
return (
|
||||
<Input
|
||||
type="file"
|
||||
onChange={(e) => {
|
||||
const files = e.target.files;
|
||||
if (files) onChange(Array.from(files));
|
||||
}}
|
||||
multiple
|
||||
/>
|
||||
);
|
||||
|
||||
case 'array':
|
||||
return <ArrayField field={field} value={value as Record<string, unknown>[]} onChange={onChange} />;
|
||||
|
||||
default:
|
||||
return (
|
||||
<Input
|
||||
value={(value as string) || ''}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={field.placeholder || `${field.label} 입력`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 날짜 범위 필드 =====
|
||||
function DateRangeField({
|
||||
value,
|
||||
onChange,
|
||||
field,
|
||||
}: {
|
||||
value?: { start?: string; end?: string };
|
||||
onChange: (value: unknown) => void;
|
||||
field: ApprovalFormField;
|
||||
}) {
|
||||
const rangeValue = value || { start: '', end: '' };
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<DatePicker
|
||||
value={rangeValue.start || ''}
|
||||
onChange={(date) => onChange({ ...rangeValue, start: date })}
|
||||
placeholder="시작일"
|
||||
/>
|
||||
</div>
|
||||
<span className="text-muted-foreground shrink-0">~</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<DatePicker
|
||||
value={rangeValue.end || ''}
|
||||
onChange={(date) => onChange({ ...rangeValue, end: date })}
|
||||
placeholder="종료일"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ===== 배열(테이블) 필드 =====
|
||||
function ArrayField({
|
||||
field,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
field: ApprovalFormField;
|
||||
value?: Record<string, unknown>[];
|
||||
onChange: (value: unknown) => void;
|
||||
}) {
|
||||
const rows = value || [];
|
||||
const columns = field.columns || [];
|
||||
|
||||
const addRow = () => {
|
||||
const newRow: Record<string, unknown> = {};
|
||||
columns.forEach((col) => {
|
||||
newRow[col.name] = col.type === 'number' ? 0 : '';
|
||||
});
|
||||
onChange([...rows, newRow]);
|
||||
};
|
||||
|
||||
const removeRow = (index: number) => {
|
||||
onChange(rows.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const updateRow = (index: number, colName: string, colValue: unknown) => {
|
||||
const updated = [...rows];
|
||||
updated[index] = { ...updated[index], [colName]: colValue };
|
||||
onChange(updated);
|
||||
};
|
||||
|
||||
if (columns.length === 0) {
|
||||
return <p className="text-sm text-muted-foreground">배열 필드 컬럼이 정의되지 않았습니다.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full border text-sm">
|
||||
<thead>
|
||||
<tr className="bg-gray-50">
|
||||
<th className="border px-2 py-1 w-10">No</th>
|
||||
{columns.map((col) => (
|
||||
<th key={col.name} className="border px-2 py-1">{col.label}</th>
|
||||
))}
|
||||
<th className="border px-2 py-1 w-10" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row, rowIdx) => (
|
||||
<tr key={rowIdx}>
|
||||
<td className="border px-2 py-1 text-center">{rowIdx + 1}</td>
|
||||
{columns.map((col) => (
|
||||
<td key={col.name} className="border px-1 py-1">
|
||||
{col.type === 'number' ? (
|
||||
<Input
|
||||
type="number"
|
||||
className="h-8 text-sm"
|
||||
value={(row[col.name] as number) ?? ''}
|
||||
onChange={(e) => updateRow(rowIdx, col.name, e.target.value ? Number(e.target.value) : '')}
|
||||
/>
|
||||
) : col.type === 'date' ? (
|
||||
<DatePicker
|
||||
size="sm"
|
||||
value={(row[col.name] as string) || ''}
|
||||
onChange={(date) => updateRow(rowIdx, col.name, date)}
|
||||
/>
|
||||
) : col.type === 'select' ? (
|
||||
<Select
|
||||
key={`${col.name}-${rowIdx}-${(row[col.name] as string) || 'empty'}`}
|
||||
value={(row[col.name] as string) || undefined}
|
||||
onValueChange={(v) => updateRow(rowIdx, col.name, v)}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-sm">
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(col.options || []).map((opt) => (
|
||||
<SelectItem key={opt} value={opt}>{opt}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<Input
|
||||
className="h-8 text-sm"
|
||||
value={(row[col.name] as string) || ''}
|
||||
onChange={(e) => updateRow(rowIdx, col.name, e.target.value)}
|
||||
/>
|
||||
)}
|
||||
</td>
|
||||
))}
|
||||
<td className="border px-1 py-1 text-center">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={() => removeRow(rowIdx)}
|
||||
>
|
||||
<Trash2 className="h-3 w-3 text-destructive" />
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={addRow}>
|
||||
<Plus className="h-3 w-3 mr-1" />
|
||||
행 추가
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
169
src/components/approval/DocumentCreate/EmploymentCertForm.tsx
Normal file
169
src/components/approval/DocumentCreate/EmploymentCertForm.tsx
Normal file
@@ -0,0 +1,169 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { DatePicker } from '@/components/ui/date-picker';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { format } from 'date-fns';
|
||||
import { getEmployeeOptions, getEmployeeAutoFill, getCompanyAutoFill } from './form-actions';
|
||||
import type { EmploymentCertData } from './types';
|
||||
|
||||
interface EmploymentCertFormProps {
|
||||
data: EmploymentCertData;
|
||||
onChange: (data: EmploymentCertData) => void;
|
||||
}
|
||||
|
||||
export function EmploymentCertForm({ data, onChange }: EmploymentCertFormProps) {
|
||||
const [employees, setEmployees] = useState<{ id: string; name: string; department: string }[]>([]);
|
||||
const [isLoadingEmployees, setIsLoadingEmployees] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
setIsLoadingEmployees(true);
|
||||
const [empResult, companyResult] = await Promise.all([
|
||||
getEmployeeOptions(),
|
||||
!data.companyName ? getCompanyAutoFill() : Promise.resolve(null),
|
||||
]);
|
||||
if (empResult.success) setEmployees(empResult.data);
|
||||
|
||||
if (companyResult?.success && companyResult.data) {
|
||||
const c = companyResult.data;
|
||||
onChange({
|
||||
...data,
|
||||
companyName: data.companyName || c.companyName,
|
||||
businessNumber: data.businessNumber || c.businessNumber,
|
||||
issueDate: data.issueDate || format(new Date(), 'yyyy-MM-dd'),
|
||||
});
|
||||
}
|
||||
setIsLoadingEmployees(false);
|
||||
}
|
||||
load();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const handleEmployeeSelect = async (employeeId: string) => {
|
||||
onChange({ ...data, employeeId });
|
||||
const result = await getEmployeeAutoFill(employeeId);
|
||||
if (result.success && result.data) {
|
||||
const e = result.data;
|
||||
onChange({
|
||||
...data,
|
||||
employeeId,
|
||||
employeeName: e.name,
|
||||
residentNumber: e.residentNumber,
|
||||
employeeAddress: e.address,
|
||||
department: e.department,
|
||||
position: e.position,
|
||||
employmentPeriodStart: e.joinDate,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 1. 인적 사항 */}
|
||||
<div className="bg-white rounded-lg border p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">인적 사항</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>대상사원 <span className="text-red-500">*</span></Label>
|
||||
<Select
|
||||
value={data.employeeId || ''}
|
||||
onValueChange={handleEmployeeSelect}
|
||||
disabled={isLoadingEmployees}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={isLoadingEmployees ? '불러오는 중...' : '사원을 선택해주세요'} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{employees.map((emp) => (
|
||||
<SelectItem key={emp.id} value={emp.id}>
|
||||
{emp.name} ({emp.department})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>성명</Label>
|
||||
<Input value={data.employeeName} disabled className="bg-gray-50" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>주민등록번호</Label>
|
||||
<Input value={data.residentNumber} disabled className="bg-gray-50" />
|
||||
</div>
|
||||
<div className="md:col-span-2 space-y-2">
|
||||
<Label>주소</Label>
|
||||
<Input value={data.employeeAddress} disabled className="bg-gray-50" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 2. 재직 사항 */}
|
||||
<div className="bg-white rounded-lg border p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">재직 사항</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>회사명</Label>
|
||||
<Input value={data.companyName} disabled className="bg-gray-50" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>사업자번호</Label>
|
||||
<Input value={data.businessNumber} disabled className="bg-gray-50" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>근무부서</Label>
|
||||
<Input value={data.department} disabled className="bg-gray-50" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>직급</Label>
|
||||
<Input value={data.position} disabled className="bg-gray-50" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>재직기간 시작</Label>
|
||||
<DatePicker
|
||||
value={data.employmentPeriodStart}
|
||||
onChange={(date) => onChange({ ...data, employmentPeriodStart: date })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>재직기간 종료</Label>
|
||||
<DatePicker
|
||||
value={data.employmentPeriodEnd}
|
||||
onChange={(date) => onChange({ ...data, employmentPeriodEnd: date })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 3. 발급 정보 */}
|
||||
<div className="bg-white rounded-lg border p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">발급 정보</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>사용 용도 <span className="text-red-500">*</span></Label>
|
||||
<Input
|
||||
placeholder="사용 용도를 입력해주세요"
|
||||
value={data.purpose}
|
||||
onChange={(e) => onChange({ ...data, purpose: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>발급일</Label>
|
||||
<DatePicker
|
||||
value={data.issueDate}
|
||||
onChange={(date) => onChange({ ...data, issueDate: date })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,18 +1,14 @@
|
||||
'use client';
|
||||
|
||||
import { Fragment } from 'react';
|
||||
import { ContentSkeleton } from '@/components/ui/skeleton';
|
||||
import { Plus, Trash2, Loader2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { CurrencyInput } from '@/components/ui/currency-input';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { DatePicker } from '@/components/ui/date-picker';
|
||||
import { formatNumber } from '@/lib/utils/amount';
|
||||
import type { ExpenseEstimateData, ExpenseEstimateItem } from './types';
|
||||
import { formatNumber as formatCurrency } from '@/lib/utils/amount';
|
||||
|
||||
interface ExpenseEstimateFormProps {
|
||||
data: ExpenseEstimateData;
|
||||
@@ -20,145 +16,191 @@ interface ExpenseEstimateFormProps {
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 비용견적서 폼 — API 템플릿 필드 매칭
|
||||
* items 배열 (expectedPaymentDate, category, amount, vendor, memo)
|
||||
* + totalExpense, accountBalance, finalDifference
|
||||
*/
|
||||
export function ExpenseEstimateForm({ data, onChange, isLoading }: ExpenseEstimateFormProps) {
|
||||
const items = data.items;
|
||||
|
||||
const handleCheckChange = (id: string, checked: boolean) => {
|
||||
const newItems = items.map((item) =>
|
||||
item.id === id ? { ...item, checked } : item
|
||||
);
|
||||
const totalExpense = newItems.reduce((sum, item) => sum + item.amount, 0);
|
||||
onChange({
|
||||
...data,
|
||||
items: newItems,
|
||||
totalExpense,
|
||||
finalDifference: data.accountBalance - totalExpense,
|
||||
});
|
||||
const recalcTotals = (items: ExpenseEstimateItem[], accountBalance: number) => {
|
||||
const totalExpense = items.reduce((sum, item) => sum + (item.amount || 0), 0);
|
||||
return { totalExpense, finalDifference: accountBalance - totalExpense };
|
||||
};
|
||||
|
||||
// 월별 그룹핑
|
||||
const groupedByMonth = items.reduce((acc, item) => {
|
||||
const month = item.expectedPaymentDate.substring(0, 7); // YYYY-MM
|
||||
if (!acc[month]) {
|
||||
acc[month] = [];
|
||||
}
|
||||
acc[month].push(item);
|
||||
return acc;
|
||||
}, {} as Record<string, ExpenseEstimateItem[]>);
|
||||
|
||||
const getMonthSubtotal = (monthItems: ExpenseEstimateItem[]) => {
|
||||
return monthItems.reduce((sum, item) => sum + item.amount, 0);
|
||||
const handleItemChange = (index: number, field: keyof ExpenseEstimateItem, value: string | number | boolean) => {
|
||||
const newItems = [...data.items];
|
||||
newItems[index] = { ...newItems[index], [field]: value };
|
||||
const { totalExpense, finalDifference } = recalcTotals(newItems, data.accountBalance);
|
||||
onChange({ ...data, items: newItems, totalExpense, finalDifference });
|
||||
};
|
||||
|
||||
const totalExpense = items.reduce((sum, item) => sum + item.amount, 0);
|
||||
const accountBalance = data.accountBalance || 10000000; // Mock 계좌 잔액
|
||||
const finalDifference = accountBalance - totalExpense;
|
||||
const handleAddItem = () => {
|
||||
const newItem: ExpenseEstimateItem = {
|
||||
id: `item-${Date.now()}`,
|
||||
checked: false,
|
||||
expectedPaymentDate: '',
|
||||
category: '',
|
||||
amount: 0,
|
||||
vendor: '',
|
||||
memo: '',
|
||||
};
|
||||
onChange({ ...data, items: [...data.items, newItem] });
|
||||
};
|
||||
|
||||
const handleDeleteChecked = () => {
|
||||
const remaining = data.items.filter(item => !item.checked);
|
||||
const { totalExpense, finalDifference } = recalcTotals(remaining, data.accountBalance);
|
||||
onChange({ ...data, items: remaining, totalExpense, finalDifference });
|
||||
};
|
||||
|
||||
const handleAccountBalanceChange = (value: string) => {
|
||||
const accountBalance = Number(value.replace(/,/g, '')) || 0;
|
||||
const { totalExpense, finalDifference } = recalcTotals(data.items, accountBalance);
|
||||
onChange({ ...data, accountBalance, totalExpense, finalDifference });
|
||||
};
|
||||
|
||||
const checkedCount = data.items.filter(i => i.checked).length;
|
||||
|
||||
// 로딩 상태
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="bg-white rounded-lg border p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">지출 예상 내역서 목록</h3>
|
||||
<ContentSkeleton type="table" rows={5} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 빈 상태
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="bg-white rounded-lg border p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">지출 예상 내역서 목록</h3>
|
||||
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
|
||||
<p>등록된 지출 예상 항목이 없습니다.</p>
|
||||
<p className="text-sm mt-1">지출 예상 항목을 먼저 등록해주세요.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground mr-2" />
|
||||
<span className="text-muted-foreground">비용견적서 데이터를 불러오는 중...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 지출 예상 내역서 정보 */}
|
||||
<div className="bg-white rounded-lg border p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">지출 예상 내역서 목록</h3>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold">비용견적서 항목</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
{checkedCount > 0 && (
|
||||
<Button variant="outline" size="sm" onClick={handleDeleteChecked} className="text-red-600">
|
||||
<Trash2 className="h-4 w-4 mr-1" />
|
||||
선택 삭제 ({checkedCount})
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="outline" size="sm" onClick={handleAddItem}>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
항목 추가
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[50px] text-center"></TableHead>
|
||||
<TableHead className="min-w-[120px]">예상 지급일</TableHead>
|
||||
<TableHead className="min-w-[150px]">항목</TableHead>
|
||||
<TableHead className="min-w-[120px] text-right">지출금액</TableHead>
|
||||
<TableHead className="min-w-[100px]">거래처</TableHead>
|
||||
<TableHead className="min-w-[150px]">적록</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{Object.entries(groupedByMonth).map(([month, monthItems]) => (
|
||||
<Fragment key={month}>
|
||||
{monthItems.map((item) => (
|
||||
<TableRow key={item.id}>
|
||||
<TableCell className="text-center">
|
||||
<Checkbox
|
||||
checked={item.checked}
|
||||
onCheckedChange={(checked) => handleCheckChange(item.id, !!checked)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>{item.expectedPaymentDate}</TableCell>
|
||||
<TableCell>{item.category}</TableCell>
|
||||
<TableCell className="text-right text-blue-600 font-medium">
|
||||
{formatCurrency(item.amount)}
|
||||
</TableCell>
|
||||
<TableCell>{item.vendor}</TableCell>
|
||||
<TableCell>{item.memo}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{/* 월별 소계 */}
|
||||
<TableRow className="bg-pink-50">
|
||||
<TableCell colSpan={2} className="font-medium">
|
||||
{month.replace('-', '년 ')}월 계
|
||||
</TableCell>
|
||||
<TableCell></TableCell>
|
||||
<TableCell className="text-right text-red-600 font-bold">
|
||||
{formatCurrency(getMonthSubtotal(monthItems))}
|
||||
</TableCell>
|
||||
<TableCell colSpan={2}></TableCell>
|
||||
</TableRow>
|
||||
</Fragment>
|
||||
))}
|
||||
{/* 항목 테이블 */}
|
||||
<div className="border rounded-md overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="bg-muted/50 border-b">
|
||||
<th className="p-2 w-10 text-center">
|
||||
<Checkbox
|
||||
checked={data.items.length > 0 && data.items.every(i => i.checked)}
|
||||
onCheckedChange={(checked) => {
|
||||
const newItems = data.items.map(i => ({ ...i, checked: !!checked }));
|
||||
onChange({ ...data, items: newItems });
|
||||
}}
|
||||
/>
|
||||
</th>
|
||||
<th className="p-2 w-10 text-center font-medium">No.</th>
|
||||
<th className="p-2 font-medium w-36">예상 지급일</th>
|
||||
<th className="p-2 font-medium">항목</th>
|
||||
<th className="p-2 font-medium w-32 text-right">지출금액</th>
|
||||
<th className="p-2 font-medium w-28">거래처</th>
|
||||
<th className="p-2 font-medium w-32">비고</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.items.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={7} className="p-8 text-center text-muted-foreground">
|
||||
항목이 없습니다. "항목 추가" 버튼을 눌러 추가하세요.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
data.items.map((item, index) => (
|
||||
<tr key={item.id} className="border-b last:border-b-0">
|
||||
<td className="p-2 text-center">
|
||||
<Checkbox
|
||||
checked={item.checked}
|
||||
onCheckedChange={(checked) => handleItemChange(index, 'checked', !!checked)}
|
||||
/>
|
||||
</td>
|
||||
<td className="p-2 text-center text-muted-foreground">{index + 1}</td>
|
||||
<td className="p-2">
|
||||
<DatePicker
|
||||
value={item.expectedPaymentDate}
|
||||
onChange={(date) => handleItemChange(index, 'expectedPaymentDate', date)}
|
||||
size="sm"
|
||||
placeholder="날짜 선택"
|
||||
/>
|
||||
</td>
|
||||
<td className="p-2">
|
||||
<Input
|
||||
value={item.category}
|
||||
onChange={(e) => handleItemChange(index, 'category', e.target.value)}
|
||||
placeholder="항목명"
|
||||
className="h-8"
|
||||
/>
|
||||
</td>
|
||||
<td className="p-2">
|
||||
<CurrencyInput
|
||||
value={item.amount || 0}
|
||||
onChange={(v) => handleItemChange(index, 'amount', v ?? 0)}
|
||||
className="h-8"
|
||||
showCurrency={false}
|
||||
/>
|
||||
</td>
|
||||
<td className="p-2">
|
||||
<Input
|
||||
value={item.vendor ?? ''}
|
||||
onChange={(e) => handleItemChange(index, 'vendor', e.target.value)}
|
||||
placeholder="거래처"
|
||||
className="h-8"
|
||||
/>
|
||||
</td>
|
||||
<td className="p-2">
|
||||
<Input
|
||||
value={item.memo ?? ''}
|
||||
onChange={(e) => handleItemChange(index, 'memo', e.target.value)}
|
||||
placeholder="비고"
|
||||
className="h-8"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* 합계 행들 */}
|
||||
<TableRow className="bg-gray-50 border-t-2">
|
||||
<TableCell colSpan={3} className="font-semibold">지출 합계</TableCell>
|
||||
<TableCell className="text-right text-red-600 font-bold">
|
||||
{formatCurrency(totalExpense)}
|
||||
</TableCell>
|
||||
<TableCell colSpan={2}></TableCell>
|
||||
</TableRow>
|
||||
<TableRow className="bg-gray-50">
|
||||
<TableCell colSpan={3} className="font-semibold">계좌 잔액</TableCell>
|
||||
<TableCell className="text-right font-bold">
|
||||
{formatCurrency(accountBalance)}
|
||||
</TableCell>
|
||||
<TableCell colSpan={2}></TableCell>
|
||||
</TableRow>
|
||||
<TableRow className="bg-gray-50">
|
||||
<TableCell colSpan={3} className="font-semibold">최종 차액</TableCell>
|
||||
<TableCell className={`text-right font-bold ${finalDifference >= 0 ? 'text-blue-600' : 'text-red-600'}`}>
|
||||
{formatCurrency(finalDifference)}
|
||||
</TableCell>
|
||||
<TableCell colSpan={2}></TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
{/* 합계 영역 */}
|
||||
<div className="mt-4 space-y-3">
|
||||
<div className="flex items-center justify-between border-t pt-4">
|
||||
<Label className="font-semibold">지출 합계</Label>
|
||||
<span className="text-lg font-bold text-red-600">
|
||||
{formatNumber(data.totalExpense)}원
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="font-semibold">계좌 잔액</Label>
|
||||
<Input
|
||||
type="text"
|
||||
value={formatNumber(data.accountBalance)}
|
||||
onChange={(e) => handleAccountBalanceChange(e.target.value)}
|
||||
className="w-48 text-right h-8"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between border-t pt-3">
|
||||
<Label className="font-semibold">최종 차액</Label>
|
||||
<span className={`text-lg font-bold ${data.finalDifference >= 0 ? 'text-blue-600' : 'text-red-600'}`}>
|
||||
{formatNumber(data.finalDifference)}원
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
141
src/components/approval/DocumentCreate/FormSelector.tsx
Normal file
141
src/components/approval/DocumentCreate/FormSelector.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { getApprovalFormsByCategory } from './form-actions';
|
||||
import type { ApprovalFormCategory, ApprovalFormItem } from './form-actions';
|
||||
|
||||
interface FormSelectorProps {
|
||||
selectedFormId?: number;
|
||||
selectedFormCode?: string;
|
||||
onFormSelect: (form: { id: number; code: string; name: string; category: string } | null) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 2단계 양식 선택 컴포넌트
|
||||
* 1단계: 카테고리 선택 (재무, 인사, 총무 등)
|
||||
* 2단계: 양식 선택 (카테고리 내 양식 목록)
|
||||
*/
|
||||
export function FormSelector({ selectedFormId, selectedFormCode, onFormSelect, disabled }: FormSelectorProps) {
|
||||
const [categories, setCategories] = useState<ApprovalFormCategory[]>([]);
|
||||
const [selectedCategory, setSelectedCategory] = useState<string>('');
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
// 양식 목록 로드
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
setIsLoading(true);
|
||||
const result = await getApprovalFormsByCategory();
|
||||
if (result.success) {
|
||||
setCategories(result.data);
|
||||
|
||||
// 기존 선택값 복원
|
||||
if (selectedFormCode || selectedFormId) {
|
||||
for (const cat of result.data) {
|
||||
const found = cat.forms.find(
|
||||
f => f.id === selectedFormId || f.code === selectedFormCode
|
||||
);
|
||||
if (found) {
|
||||
setSelectedCategory(cat.category);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
setIsLoading(false);
|
||||
};
|
||||
load();
|
||||
}, [selectedFormId, selectedFormCode]);
|
||||
|
||||
// 현재 카테고리의 양식 목록
|
||||
const currentForms = categories.find(c => c.category === selectedCategory)?.forms || [];
|
||||
|
||||
// 카테고리 변경
|
||||
const handleCategoryChange = useCallback((category: string) => {
|
||||
setSelectedCategory(category);
|
||||
onFormSelect(null); // 카테고리 변경 시 양식 선택 초기화
|
||||
}, [onFormSelect]);
|
||||
|
||||
// 양식 선택
|
||||
const handleFormSelect = useCallback((formId: string) => {
|
||||
const form = currentForms.find(f => String(f.id) === formId);
|
||||
if (form) {
|
||||
onFormSelect({
|
||||
id: form.id,
|
||||
code: form.code,
|
||||
name: form.name,
|
||||
category: form.category,
|
||||
});
|
||||
}
|
||||
}, [currentForms, onFormSelect]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>문서 분류</Label>
|
||||
<Select disabled><SelectTrigger><SelectValue placeholder="로딩 중..." /></SelectTrigger></Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>양식 선택</Label>
|
||||
<Select disabled><SelectTrigger><SelectValue placeholder="로딩 중..." /></SelectTrigger></Select>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* 1단계: 카테고리 선택 */}
|
||||
<div className="space-y-2">
|
||||
<Label>문서 분류</Label>
|
||||
<Select
|
||||
value={selectedCategory}
|
||||
onValueChange={handleCategoryChange}
|
||||
disabled={disabled}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="분류 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{categories.map((cat) => (
|
||||
<SelectItem key={cat.category} value={cat.category}>
|
||||
{cat.categoryLabel} ({cat.forms.length})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 2단계: 양식 선택 */}
|
||||
<div className="space-y-2">
|
||||
<Label>양식 선택</Label>
|
||||
<Select
|
||||
key={`form-${selectedCategory}-${selectedFormId}`}
|
||||
value={selectedFormId ? String(selectedFormId) : ''}
|
||||
onValueChange={handleFormSelect}
|
||||
disabled={disabled || !selectedCategory}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={selectedCategory ? '양식 선택' : '분류를 먼저 선택하세요'} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{currentForms.map((form) => (
|
||||
<SelectItem key={form.id} value={String(form.id)}>
|
||||
{form.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
162
src/components/approval/DocumentCreate/LeaveNotice1stForm.tsx
Normal file
162
src/components/approval/DocumentCreate/LeaveNotice1stForm.tsx
Normal file
@@ -0,0 +1,162 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { DatePicker } from '@/components/ui/date-picker';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { getEmployeeOptions, getEmployeeAutoFill, getLeaveBalanceAutoFill } from './form-actions';
|
||||
import type { LeaveNotice1stData } from './types';
|
||||
|
||||
interface LeaveNotice1stFormProps {
|
||||
data: LeaveNotice1stData;
|
||||
onChange: (data: LeaveNotice1stData) => void;
|
||||
}
|
||||
|
||||
const DEFAULT_LEGAL_NOTICE = `근로기준법 제61조에 의거하여 미사용 연차유급휴가에 대하여 사용을 촉진합니다.\n\n아래 잔여 연차를 기한 내에 사용하여 주시기 바랍니다. 기한 내 미사용 시 사용시기를 지정하여 통보할 예정이며, 회사가 지정한 시기에 사용하지 않을 경우 해당 연차는 소멸됩니다.`;
|
||||
|
||||
export function LeaveNotice1stForm({ data, onChange }: LeaveNotice1stFormProps) {
|
||||
const [employees, setEmployees] = useState<{ id: string; name: string; department: string }[]>([]);
|
||||
const [isLoadingEmployees, setIsLoadingEmployees] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
async function loadEmployees() {
|
||||
setIsLoadingEmployees(true);
|
||||
const result = await getEmployeeOptions();
|
||||
if (result.success) setEmployees(result.data);
|
||||
setIsLoadingEmployees(false);
|
||||
}
|
||||
loadEmployees();
|
||||
}, []);
|
||||
|
||||
const handleEmployeeSelect = async (employeeId: string) => {
|
||||
onChange({ ...data, employeeId });
|
||||
|
||||
// 직원 정보 + 연차 잔여 동시 조회
|
||||
const [empResult, leaveResult] = await Promise.all([
|
||||
getEmployeeAutoFill(employeeId),
|
||||
getLeaveBalanceAutoFill(parseInt(employeeId)),
|
||||
]);
|
||||
|
||||
const updates: Partial<LeaveNotice1stData> = { employeeId };
|
||||
|
||||
if (empResult.success && empResult.data) {
|
||||
updates.department = empResult.data.department;
|
||||
updates.position = empResult.data.position;
|
||||
}
|
||||
|
||||
if (leaveResult.success && leaveResult.data) {
|
||||
updates.totalDays = leaveResult.data.totalDays;
|
||||
updates.usedDays = leaveResult.data.usedDays;
|
||||
updates.remainingDays = leaveResult.data.remainingDays;
|
||||
}
|
||||
|
||||
if (!data.legalNotice) {
|
||||
updates.legalNotice = DEFAULT_LEGAL_NOTICE;
|
||||
}
|
||||
|
||||
onChange({ ...data, ...updates });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 1. 수신자 */}
|
||||
<div className="bg-white rounded-lg border p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">수신자</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>대상직원 <span className="text-red-500">*</span></Label>
|
||||
<Select
|
||||
value={data.employeeId || ''}
|
||||
onValueChange={handleEmployeeSelect}
|
||||
disabled={isLoadingEmployees}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={isLoadingEmployees ? '불러오는 중...' : '직원을 선택해주세요'} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{employees.map((emp) => (
|
||||
<SelectItem key={emp.id} value={emp.id}>
|
||||
{emp.name} ({emp.department})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>부서</Label>
|
||||
<Input value={data.department} disabled className="bg-gray-50" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>직급</Label>
|
||||
<Input value={data.position} disabled className="bg-gray-50" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 2. 연차 현황 */}
|
||||
<div className="bg-white rounded-lg border p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">연차 현황</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>발생 <span className="text-red-500">*</span></Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={data.totalDays || ''}
|
||||
onChange={(e) => onChange({ ...data, totalDays: Number(e.target.value) })}
|
||||
placeholder="발생 일수"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>사용</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={data.usedDays || ''}
|
||||
onChange={(e) => onChange({ ...data, usedDays: Number(e.target.value) })}
|
||||
placeholder="사용 일수"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>잔여</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={data.remainingDays || ''}
|
||||
onChange={(e) => onChange({ ...data, remainingDays: Number(e.target.value) })}
|
||||
placeholder="잔여 일수"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 3. 제출 기한 */}
|
||||
<div className="bg-white rounded-lg border p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">제출 기한</h3>
|
||||
<div className="space-y-2">
|
||||
<Label>사용 계획 제출 기한 <span className="text-red-500">*</span></Label>
|
||||
<DatePicker
|
||||
value={data.deadline}
|
||||
onChange={(date) => onChange({ ...data, deadline: date })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 4. 법적 통지 문구 */}
|
||||
<div className="bg-white rounded-lg border p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">법적 통지 문구</h3>
|
||||
<Textarea
|
||||
value={data.legalNotice}
|
||||
onChange={(e) => onChange({ ...data, legalNotice: e.target.value })}
|
||||
className="min-h-[150px]"
|
||||
placeholder="법적 통지 문구"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
192
src/components/approval/DocumentCreate/LeaveNotice2ndForm.tsx
Normal file
192
src/components/approval/DocumentCreate/LeaveNotice2ndForm.tsx
Normal file
@@ -0,0 +1,192 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { DatePicker } from '@/components/ui/date-picker';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Plus, X } from 'lucide-react';
|
||||
import { getEmployeeOptions, getEmployeeAutoFill, getLeaveBalanceAutoFill } from './form-actions';
|
||||
import type { LeaveNotice2ndData } from './types';
|
||||
|
||||
interface LeaveNotice2ndFormProps {
|
||||
data: LeaveNotice2ndData;
|
||||
onChange: (data: LeaveNotice2ndData) => void;
|
||||
}
|
||||
|
||||
const LEGAL_NOTICE_PARAGRAPHS = [
|
||||
'귀하는 연차 사용촉진 1차 통보 이후에도 연차 사용 시기를 제출하지 않아 근로기준법 제61조에 따라 회사가 다음과 같이 휴가 사용일을 지정합니다.',
|
||||
'위 지정된 날짜에 연차휴가를 사용하여 주시기 바랍니다.',
|
||||
'본 통지서는 근로기준법 제61조에 따른 연차 사용촉진 절차에 의한 통보입니다.',
|
||||
];
|
||||
|
||||
const LEGAL_NOTICE_TEXT = LEGAL_NOTICE_PARAGRAPHS.join('\n\n');
|
||||
|
||||
export function LeaveNotice2ndForm({ data, onChange }: LeaveNotice2ndFormProps) {
|
||||
const [employees, setEmployees] = useState<{ id: string; name: string; department: string }[]>([]);
|
||||
const [isLoadingEmployees, setIsLoadingEmployees] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
async function loadEmployees() {
|
||||
setIsLoadingEmployees(true);
|
||||
const result = await getEmployeeOptions();
|
||||
if (result.success) setEmployees(result.data);
|
||||
setIsLoadingEmployees(false);
|
||||
}
|
||||
loadEmployees();
|
||||
}, []);
|
||||
|
||||
// 법적 통지 문구 자동 설정
|
||||
useEffect(() => {
|
||||
if (!data.legalNotice) {
|
||||
onChange({ ...data, legalNotice: LEGAL_NOTICE_TEXT });
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const handleEmployeeSelect = async (employeeId: string) => {
|
||||
onChange({ ...data, employeeId });
|
||||
|
||||
const [empResult, leaveResult] = await Promise.all([
|
||||
getEmployeeAutoFill(employeeId),
|
||||
getLeaveBalanceAutoFill(parseInt(employeeId)),
|
||||
]);
|
||||
|
||||
const updates: Partial<LeaveNotice2ndData> = { employeeId };
|
||||
|
||||
if (empResult.success && empResult.data) {
|
||||
updates.department = empResult.data.department;
|
||||
updates.position = empResult.data.position;
|
||||
}
|
||||
|
||||
if (leaveResult.success && leaveResult.data) {
|
||||
updates.remainingDays = leaveResult.data.remainingDays;
|
||||
}
|
||||
|
||||
onChange({ ...data, ...updates });
|
||||
};
|
||||
|
||||
const handleAddDate = () => {
|
||||
onChange({ ...data, designatedDates: [...data.designatedDates, ''] });
|
||||
};
|
||||
|
||||
const handleDateChange = (index: number, value: string) => {
|
||||
const newDates = [...data.designatedDates];
|
||||
newDates[index] = value;
|
||||
onChange({ ...data, designatedDates: newDates });
|
||||
};
|
||||
|
||||
const handleRemoveDate = (index: number) => {
|
||||
const newDates = data.designatedDates.filter((_, i) => i !== index);
|
||||
onChange({ ...data, designatedDates: newDates.length > 0 ? newDates : [''] });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 1. 수신자 정보 */}
|
||||
<div className="bg-white rounded-lg border p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">1. 수신자 정보</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>대상 직원 <span className="text-red-500">*</span></Label>
|
||||
<Select
|
||||
value={data.employeeId || ''}
|
||||
onValueChange={handleEmployeeSelect}
|
||||
disabled={isLoadingEmployees}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={isLoadingEmployees ? '불러오는 중...' : '직원을 선택해주세요'} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{employees.map((emp) => (
|
||||
<SelectItem key={emp.id} value={emp.id}>
|
||||
{emp.name} ({emp.department})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>부서</Label>
|
||||
<Input value={data.department} disabled className="bg-gray-50" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>직급</Label>
|
||||
<Input value={data.position} disabled className="bg-gray-50" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 2. 잔여 연차 */}
|
||||
<div className="bg-white rounded-lg border p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">2. 잔여 연차</h3>
|
||||
<div className="space-y-2">
|
||||
<Label>잔여연차 <span className="text-red-500">*</span></Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={data.remainingDays || ''}
|
||||
onChange={(e) => onChange({ ...data, remainingDays: Number(e.target.value) })}
|
||||
placeholder="잔여 일수"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 3. 회사 지정 휴가일 */}
|
||||
<div className="bg-white rounded-lg border p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">
|
||||
3. 회사 지정 휴가일 <span className="text-red-500">*</span>
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
{data.designatedDates.map((date, index) => (
|
||||
<div key={index} className="flex items-center gap-3">
|
||||
<span className="text-sm font-medium text-gray-600 w-6">{index + 1}.</span>
|
||||
<DatePicker
|
||||
value={date}
|
||||
onChange={(value) => handleDateChange(index, value)}
|
||||
placeholder="날짜를 선택하세요"
|
||||
/>
|
||||
{data.designatedDates.length > 1 && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleRemoveDate(index)}
|
||||
className="text-red-500 hover:text-red-700"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleAddDate}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
휴가일 추가
|
||||
</Button>
|
||||
<p className="text-sm text-muted-foreground">* 잔여연차 일수만큼 지정해주세요.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 4. 법적 통지 문구 */}
|
||||
<div className="bg-white rounded-lg border p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">4. 법적 통지 문구</h3>
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4 space-y-3">
|
||||
{LEGAL_NOTICE_PARAGRAPHS.map((paragraph, index) => (
|
||||
<p key={index} className="text-sm text-red-800">{paragraph}</p>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
169
src/components/approval/DocumentCreate/OfficialDocumentForm.tsx
Normal file
169
src/components/approval/DocumentCreate/OfficialDocumentForm.tsx
Normal file
@@ -0,0 +1,169 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { DatePicker } from '@/components/ui/date-picker';
|
||||
import { format } from 'date-fns';
|
||||
import { getCompanyAutoFill } from './form-actions';
|
||||
import type { OfficialDocumentData } from './types';
|
||||
|
||||
interface OfficialDocumentFormProps {
|
||||
data: OfficialDocumentData;
|
||||
onChange: (data: OfficialDocumentData) => void;
|
||||
}
|
||||
|
||||
export function OfficialDocumentForm({ data, onChange }: OfficialDocumentFormProps) {
|
||||
const [isLoadingCompany, setIsLoadingCompany] = useState(false);
|
||||
|
||||
// 회사 정보 자동입력
|
||||
useEffect(() => {
|
||||
async function loadCompanyInfo() {
|
||||
setIsLoadingCompany(true);
|
||||
const result = await getCompanyAutoFill();
|
||||
if (result.success && result.data) {
|
||||
onChange({
|
||||
...data,
|
||||
documentDate: data.documentDate || format(new Date(), 'yyyy-MM-dd'),
|
||||
companyName: data.companyName || result.data.companyName,
|
||||
representativeName: data.representativeName || result.data.representativeName,
|
||||
address: data.address || result.data.address,
|
||||
phone: data.phone || result.data.phone,
|
||||
fax: data.fax || result.data.fax,
|
||||
email: data.email || result.data.email,
|
||||
});
|
||||
}
|
||||
setIsLoadingCompany(false);
|
||||
}
|
||||
if (!data.companyName) {
|
||||
loadCompanyInfo();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 1. 문서 정보 */}
|
||||
<div className="bg-white rounded-lg border p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">1. 문서 정보</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>문서번호</Label>
|
||||
<Input
|
||||
placeholder="예: 2026030601"
|
||||
value={data.documentNumber}
|
||||
onChange={(e) => onChange({ ...data, documentNumber: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>일자 <span className="text-red-500">*</span></Label>
|
||||
<DatePicker
|
||||
value={data.documentDate}
|
||||
onChange={(date) => onChange({ ...data, documentDate: date })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 2. 수신 정보 */}
|
||||
<div className="bg-white rounded-lg border p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">2. 수신 정보</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>수신 <span className="text-red-500">*</span></Label>
|
||||
<Input
|
||||
placeholder="수신처"
|
||||
value={data.recipient}
|
||||
onChange={(e) => onChange({ ...data, recipient: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>참조</Label>
|
||||
<Input
|
||||
placeholder="참조 (선택사항)"
|
||||
value={data.reference}
|
||||
onChange={(e) => onChange({ ...data, reference: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>제목 <span className="text-red-500">*</span></Label>
|
||||
<Input
|
||||
placeholder="공문서 제목"
|
||||
value={data.title}
|
||||
onChange={(e) => onChange({ ...data, title: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 3. 본문 */}
|
||||
<div className="bg-white rounded-lg border p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">3. 본문</h3>
|
||||
<Textarea
|
||||
placeholder="본문 내용을 입력해주세요"
|
||||
value={data.body}
|
||||
onChange={(e) => onChange({ ...data, body: e.target.value })}
|
||||
className="min-h-[200px]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 4. 붙임 (첨부서류) */}
|
||||
<div className="bg-white rounded-lg border p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">4. 붙임 (첨부서류)</h3>
|
||||
<Textarea
|
||||
placeholder="첨부 서류 목록을 입력해주세요"
|
||||
value={data.attachment}
|
||||
onChange={(e) => onChange({ ...data, attachment: e.target.value })}
|
||||
className="min-h-[100px]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 5. 발신자 (회사 자동입력) */}
|
||||
<div className="bg-white rounded-lg border p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">
|
||||
5. 발신자
|
||||
{isLoadingCompany && <span className="text-sm text-gray-400 ml-2">불러오는 중...</span>}
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>상호</Label>
|
||||
<Input value={data.companyName} disabled className="bg-gray-50" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>대표이사</Label>
|
||||
<Input value={data.representativeName} disabled className="bg-gray-50" />
|
||||
</div>
|
||||
<div className="md:col-span-2 space-y-2">
|
||||
<Label>주소</Label>
|
||||
<Input value={data.address} disabled className="bg-gray-50" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>전화</Label>
|
||||
<Input
|
||||
value={data.phone}
|
||||
onChange={(e) => onChange({ ...data, phone: e.target.value })}
|
||||
placeholder="전화번호"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>팩스</Label>
|
||||
<Input
|
||||
value={data.fax}
|
||||
onChange={(e) => onChange({ ...data, fax: e.target.value })}
|
||||
placeholder="팩스번호"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>이메일</Label>
|
||||
<Input
|
||||
value={data.email}
|
||||
onChange={(e) => onChange({ ...data, email: e.target.value })}
|
||||
placeholder="이메일"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
159
src/components/approval/DocumentCreate/PowerOfAttorneyForm.tsx
Normal file
159
src/components/approval/DocumentCreate/PowerOfAttorneyForm.tsx
Normal file
@@ -0,0 +1,159 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { DatePicker } from '@/components/ui/date-picker';
|
||||
import { getCompanyAutoFill } from './form-actions';
|
||||
import type { PowerOfAttorneyData } from './types';
|
||||
|
||||
interface PowerOfAttorneyFormProps {
|
||||
data: PowerOfAttorneyData;
|
||||
onChange: (data: PowerOfAttorneyData) => void;
|
||||
}
|
||||
|
||||
export function PowerOfAttorneyForm({ data, onChange }: PowerOfAttorneyFormProps) {
|
||||
const [isLoadingCompany, setIsLoadingCompany] = useState(false);
|
||||
|
||||
// 위임인(회사) 정보 자동입력
|
||||
useEffect(() => {
|
||||
async function loadCompanyInfo() {
|
||||
setIsLoadingCompany(true);
|
||||
const result = await getCompanyAutoFill();
|
||||
if (result.success && result.data) {
|
||||
onChange({
|
||||
...data,
|
||||
principalCompanyName: data.principalCompanyName || result.data.companyName,
|
||||
principalBusinessNumber: data.principalBusinessNumber || result.data.businessNumber,
|
||||
principalAddress: data.principalAddress || result.data.address,
|
||||
principalRepresentative: data.principalRepresentative || result.data.representativeName,
|
||||
});
|
||||
}
|
||||
setIsLoadingCompany(false);
|
||||
}
|
||||
if (!data.principalCompanyName) {
|
||||
loadCompanyInfo();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 1. 위임인 (회사 정보) */}
|
||||
<div className="bg-white rounded-lg border p-6">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<h3 className="text-lg font-semibold">위임인</h3>
|
||||
{isLoadingCompany && <span className="text-sm text-gray-400">불러오는 중...</span>}
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>법인명</Label>
|
||||
<Input value={data.principalCompanyName} disabled className="bg-gray-50" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>사업자번호</Label>
|
||||
<Input value={data.principalBusinessNumber} disabled className="bg-gray-50" />
|
||||
</div>
|
||||
<div className="md:col-span-2 space-y-2">
|
||||
<Label>주소</Label>
|
||||
<Input value={data.principalAddress} disabled className="bg-gray-50" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>대표자</Label>
|
||||
<Input value={data.principalRepresentative} disabled className="bg-gray-50" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 2. 수임인 */}
|
||||
<div className="bg-white rounded-lg border p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">수임인</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>성명 <span className="text-red-500">*</span></Label>
|
||||
<Input
|
||||
placeholder="성명을 입력해주세요"
|
||||
value={data.agentName}
|
||||
onChange={(e) => onChange({ ...data, agentName: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>생년월일</Label>
|
||||
<DatePicker
|
||||
value={data.agentBirthDate}
|
||||
onChange={(date) => onChange({ ...data, agentBirthDate: date })}
|
||||
/>
|
||||
</div>
|
||||
<div className="md:col-span-2 space-y-2">
|
||||
<Label>주소</Label>
|
||||
<Input
|
||||
placeholder="주소를 입력해주세요"
|
||||
value={data.agentAddress}
|
||||
onChange={(e) => onChange({ ...data, agentAddress: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>연락처</Label>
|
||||
<Input
|
||||
placeholder="연락처를 입력해주세요"
|
||||
value={data.agentPhone}
|
||||
onChange={(e) => onChange({ ...data, agentPhone: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>소속</Label>
|
||||
<Input
|
||||
placeholder="소속을 입력해주세요"
|
||||
value={data.agentDepartment}
|
||||
onChange={(e) => onChange({ ...data, agentDepartment: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 3. 위임 사항 */}
|
||||
<div className="bg-white rounded-lg border p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">위임 사항</h3>
|
||||
<Textarea
|
||||
placeholder="위임 사항을 입력해주세요"
|
||||
value={data.delegationDetails}
|
||||
onChange={(e) => onChange({ ...data, delegationDetails: e.target.value })}
|
||||
className="min-h-[120px]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 4. 위임 기간 */}
|
||||
<div className="bg-white rounded-lg border p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">위임 기간</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>시작일</Label>
|
||||
<DatePicker
|
||||
value={data.delegationPeriodStart}
|
||||
onChange={(date) => onChange({ ...data, delegationPeriodStart: date })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>종료일</Label>
|
||||
<DatePicker
|
||||
value={data.delegationPeriodEnd}
|
||||
onChange={(date) => onChange({ ...data, delegationPeriodEnd: date })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 5. 첨부 서류 */}
|
||||
<div className="bg-white rounded-lg border p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">첨부 서류</h3>
|
||||
<Textarea
|
||||
placeholder="첨부 서류 목록을 입력해주세요"
|
||||
value={data.attachedDocuments}
|
||||
onChange={(e) => onChange({ ...data, attachedDocuments: e.target.value })}
|
||||
className="min-h-[80px]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
304
src/components/approval/DocumentCreate/QuotationForm.tsx
Normal file
304
src/components/approval/DocumentCreate/QuotationForm.tsx
Normal file
@@ -0,0 +1,304 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Plus, Trash2 } from 'lucide-react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { DatePicker } from '@/components/ui/date-picker';
|
||||
import { CurrencyInput } from '@/components/ui/currency-input';
|
||||
import { format } from 'date-fns';
|
||||
import { formatNumber } from '@/lib/utils/amount';
|
||||
import { getCompanyAutoFill } from './form-actions';
|
||||
import type { QuotationData, QuotationItem } from './types';
|
||||
|
||||
interface QuotationFormProps {
|
||||
data: QuotationData;
|
||||
onChange: (data: QuotationData) => void;
|
||||
}
|
||||
|
||||
function createEmptyItem(): QuotationItem {
|
||||
return {
|
||||
id: `item-${Date.now()}`,
|
||||
name: '',
|
||||
specification: '',
|
||||
quantity: 1,
|
||||
unitPrice: 0,
|
||||
supplyAmount: 0,
|
||||
tax: 0,
|
||||
note: '',
|
||||
};
|
||||
}
|
||||
|
||||
function calcItem(item: QuotationItem, autoTax: boolean): QuotationItem {
|
||||
const supplyAmount = item.quantity * item.unitPrice;
|
||||
const tax = autoTax ? Math.round(supplyAmount * 0.1) : 0;
|
||||
return { ...item, supplyAmount, tax };
|
||||
}
|
||||
|
||||
export function QuotationForm({ data, onChange }: QuotationFormProps) {
|
||||
const [isLoadingCompany, setIsLoadingCompany] = useState(false);
|
||||
|
||||
// 공급자(회사) 정보 자동입력
|
||||
useEffect(() => {
|
||||
async function loadCompanyInfo() {
|
||||
setIsLoadingCompany(true);
|
||||
const result = await getCompanyAutoFill();
|
||||
if (result.success && result.data) {
|
||||
const c = result.data;
|
||||
onChange({
|
||||
...data,
|
||||
businessNumber: data.businessNumber || c.businessNumber,
|
||||
companyName: data.companyName || c.companyName,
|
||||
representativeName: data.representativeName || c.representativeName,
|
||||
companyAddress: data.companyAddress || c.address,
|
||||
businessType: data.businessType || c.businessType,
|
||||
businessCategory: data.businessCategory || c.businessCategory,
|
||||
companyPhone: data.companyPhone || c.phone,
|
||||
quotationDate: data.quotationDate || format(new Date(), 'yyyy-MM-dd'),
|
||||
});
|
||||
}
|
||||
setIsLoadingCompany(false);
|
||||
}
|
||||
if (!data.companyName) {
|
||||
loadCompanyInfo();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const handleAddItem = () => {
|
||||
onChange({ ...data, items: [...data.items, createEmptyItem()] });
|
||||
};
|
||||
|
||||
const handleRemoveItem = (index: number) => {
|
||||
const items = data.items.filter((_, i) => i !== index);
|
||||
onChange({ ...data, items });
|
||||
};
|
||||
|
||||
const handleItemChange = (index: number, field: keyof QuotationItem, value: string | number) => {
|
||||
const items = [...data.items];
|
||||
const item = { ...items[index], [field]: value };
|
||||
items[index] = calcItem(item, data.autoTax);
|
||||
onChange({ ...data, items });
|
||||
};
|
||||
|
||||
const handleAutoTaxChange = (checked: boolean) => {
|
||||
const items = data.items.map((item) => calcItem(item, checked));
|
||||
onChange({ ...data, autoTax: checked, items });
|
||||
};
|
||||
|
||||
// 합계 계산
|
||||
const totalSupply = data.items.reduce((sum, item) => sum + item.supplyAmount, 0);
|
||||
const totalTax = data.items.reduce((sum, item) => sum + item.tax, 0);
|
||||
const grandTotal = totalSupply + totalTax;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 1. 수신 정보 */}
|
||||
<div className="bg-white rounded-lg border p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">수신 정보</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>수신/고객명 <span className="text-red-500">*</span></Label>
|
||||
<Input
|
||||
placeholder="고객명을 입력해주세요"
|
||||
value={data.recipientName}
|
||||
onChange={(e) => onChange({ ...data, recipientName: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>견적일자 <span className="text-red-500">*</span></Label>
|
||||
<DatePicker
|
||||
value={data.quotationDate}
|
||||
onChange={(date) => onChange({ ...data, quotationDate: date })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 2. 공급자 정보 (회사 자동입력) */}
|
||||
<div className="bg-white rounded-lg border p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">
|
||||
공급자
|
||||
{isLoadingCompany && <span className="text-sm text-gray-400 ml-2">불러오는 중...</span>}
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>사업자번호</Label>
|
||||
<Input value={data.businessNumber} disabled className="bg-gray-50" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>상호</Label>
|
||||
<Input value={data.companyName} disabled className="bg-gray-50" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>대표자</Label>
|
||||
<Input value={data.representativeName} disabled className="bg-gray-50" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>소재지</Label>
|
||||
<Input value={data.companyAddress} disabled className="bg-gray-50" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>업태</Label>
|
||||
<Input
|
||||
value={data.businessType}
|
||||
onChange={(e) => onChange({ ...data, businessType: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>업종</Label>
|
||||
<Input
|
||||
value={data.businessCategory}
|
||||
onChange={(e) => onChange({ ...data, businessCategory: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>연락처</Label>
|
||||
<Input value={data.companyPhone} disabled className="bg-gray-50" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>계좌번호</Label>
|
||||
<Input
|
||||
placeholder="(은행명) 계좌번호"
|
||||
value={data.accountNumber}
|
||||
onChange={(e) => onChange({ ...data, accountNumber: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 3. 견적 품목 */}
|
||||
<div className="bg-white rounded-lg border p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold">견적 품목</h3>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Checkbox
|
||||
id="autoTax"
|
||||
checked={data.autoTax}
|
||||
onCheckedChange={(checked) => handleAutoTaxChange(checked === true)}
|
||||
/>
|
||||
<Label htmlFor="autoTax" className="text-sm cursor-pointer">부가세 자동(10%)</Label>
|
||||
</div>
|
||||
<Button type="button" variant="outline" size="sm" onClick={handleAddItem}>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
품목 추가
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{data.items.length === 0 ? (
|
||||
<div className="text-center text-sm text-gray-400 py-8 border rounded">
|
||||
항목을 추가해주세요
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm border">
|
||||
<thead>
|
||||
<tr className="bg-gray-50">
|
||||
<th className="border px-2 py-2 w-10">#</th>
|
||||
<th className="border px-2 py-2">품명</th>
|
||||
<th className="border px-2 py-2">규격</th>
|
||||
<th className="border px-2 py-2 w-20">수량</th>
|
||||
<th className="border px-2 py-2 w-28">단가</th>
|
||||
<th className="border px-2 py-2 w-28">공급가액</th>
|
||||
<th className="border px-2 py-2 w-24">세액</th>
|
||||
<th className="border px-2 py-2">비고</th>
|
||||
<th className="border px-2 py-2 w-10"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.items.map((item, index) => (
|
||||
<tr key={item.id}>
|
||||
<td className="border px-2 py-1 text-center">{index + 1}</td>
|
||||
<td className="border px-1 py-1">
|
||||
<Input
|
||||
value={item.name}
|
||||
onChange={(e) => handleItemChange(index, 'name', e.target.value)}
|
||||
className="h-8 border-0"
|
||||
placeholder="품명"
|
||||
/>
|
||||
</td>
|
||||
<td className="border px-1 py-1">
|
||||
<Input
|
||||
value={item.specification}
|
||||
onChange={(e) => handleItemChange(index, 'specification', e.target.value)}
|
||||
className="h-8 border-0"
|
||||
placeholder="규격"
|
||||
/>
|
||||
</td>
|
||||
<td className="border px-1 py-1">
|
||||
<Input
|
||||
type="number"
|
||||
value={item.quantity || ''}
|
||||
onChange={(e) => handleItemChange(index, 'quantity', Number(e.target.value))}
|
||||
className="h-8 border-0 text-right"
|
||||
/>
|
||||
</td>
|
||||
<td className="border px-1 py-1">
|
||||
<CurrencyInput
|
||||
value={item.unitPrice}
|
||||
onChange={(value) => handleItemChange(index, 'unitPrice', value ?? 0)}
|
||||
className="h-8 border-0"
|
||||
/>
|
||||
</td>
|
||||
<td className="border px-2 py-1 text-right bg-gray-50">
|
||||
{formatNumber(item.supplyAmount)}
|
||||
</td>
|
||||
<td className="border px-2 py-1 text-right bg-gray-50">
|
||||
{formatNumber(item.tax)}
|
||||
</td>
|
||||
<td className="border px-1 py-1">
|
||||
<Input
|
||||
value={item.note}
|
||||
onChange={(e) => handleItemChange(index, 'note', e.target.value)}
|
||||
className="h-8 border-0"
|
||||
/>
|
||||
</td>
|
||||
<td className="border px-1 py-1 text-center">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleRemoveItem(index)}
|
||||
className="h-7 w-7 p-0 text-red-500 hover:text-red-700"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr className="bg-gray-50 font-semibold">
|
||||
<td colSpan={5} className="border px-2 py-2 text-right">합 계</td>
|
||||
<td className="border px-2 py-2 text-right">{formatNumber(totalSupply)}</td>
|
||||
<td className="border px-2 py-2 text-right">{formatNumber(totalTax)}</td>
|
||||
<td colSpan={2} className="border"></td>
|
||||
</tr>
|
||||
<tr className="bg-blue-50 font-semibold">
|
||||
<td colSpan={5} className="border px-2 py-2 text-right">견적금액 (공급가액 + 세액)</td>
|
||||
<td colSpan={4} className="border px-2 py-2 text-right">{formatNumber(grandTotal)}원</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 4. 특이사항 */}
|
||||
<div className="bg-white rounded-lg border p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">특이사항</h3>
|
||||
<Textarea
|
||||
placeholder="예: *부가세액 별도* 특이사항을 적어주세요"
|
||||
value={data.specialNotes}
|
||||
onChange={(e) => onChange({ ...data, specialNotes: e.target.value })}
|
||||
className="min-h-[80px]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
175
src/components/approval/DocumentCreate/ResignationForm.tsx
Normal file
175
src/components/approval/DocumentCreate/ResignationForm.tsx
Normal file
@@ -0,0 +1,175 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { DatePicker } from '@/components/ui/date-picker';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { format } from 'date-fns';
|
||||
import { getEmployeeOptions, getEmployeeAutoFill } from './form-actions';
|
||||
import type { ResignationData } from './types';
|
||||
|
||||
interface ResignationFormProps {
|
||||
data: ResignationData;
|
||||
onChange: (data: ResignationData) => void;
|
||||
}
|
||||
|
||||
const REASON_OPTIONS = [
|
||||
{ value: 'personal', label: '개인 사유' },
|
||||
{ value: 'health', label: '건강 문제' },
|
||||
{ value: 'family', label: '가정 사정' },
|
||||
{ value: 'career', label: '이직/전직' },
|
||||
{ value: 'study', label: '학업' },
|
||||
{ value: 'other', label: '기타' },
|
||||
];
|
||||
|
||||
export function ResignationForm({ data, onChange }: ResignationFormProps) {
|
||||
const [employees, setEmployees] = useState<{ id: string; name: string; department: string }[]>([]);
|
||||
const [isLoadingEmployees, setIsLoadingEmployees] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
async function loadEmployees() {
|
||||
setIsLoadingEmployees(true);
|
||||
const result = await getEmployeeOptions();
|
||||
if (result.success) setEmployees(result.data);
|
||||
setIsLoadingEmployees(false);
|
||||
}
|
||||
loadEmployees();
|
||||
}, []);
|
||||
|
||||
const handleEmployeeSelect = async (employeeId: string) => {
|
||||
onChange({ ...data, employeeId });
|
||||
const result = await getEmployeeAutoFill(employeeId);
|
||||
if (result.success && result.data) {
|
||||
const e = result.data;
|
||||
onChange({
|
||||
...data,
|
||||
employeeId,
|
||||
employeeName: e.name,
|
||||
department: e.department,
|
||||
position: e.position,
|
||||
residentNumber: e.residentNumber,
|
||||
joinDate: e.joinDate,
|
||||
employeeAddress: e.address,
|
||||
submitDate: data.submitDate || format(new Date(), 'yyyy-MM-dd'),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 대상 사원 선택 */}
|
||||
<div className="bg-white rounded-lg border p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">대상 사원</h3>
|
||||
<div className="space-y-2">
|
||||
<Label>대상사원 <span className="text-red-500">*</span></Label>
|
||||
<Select
|
||||
value={data.employeeId || ''}
|
||||
onValueChange={handleEmployeeSelect}
|
||||
disabled={isLoadingEmployees}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={isLoadingEmployees ? '불러오는 중...' : '사원을 선택해주세요'} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{employees.map((emp) => (
|
||||
<SelectItem key={emp.id} value={emp.id}>
|
||||
{emp.name} ({emp.department})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 인적 사항 */}
|
||||
<div className="bg-white rounded-lg border p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">인적 사항</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>소속</Label>
|
||||
<Input value={data.department} disabled className="bg-gray-50" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>직위</Label>
|
||||
<Input value={data.position} disabled className="bg-gray-50" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>성명</Label>
|
||||
<Input value={data.employeeName} disabled className="bg-gray-50" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>주민등록번호</Label>
|
||||
<Input value={data.residentNumber} disabled className="bg-gray-50" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>입사일</Label>
|
||||
<Input value={data.joinDate} disabled className="bg-gray-50" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>퇴사예정일 <span className="text-red-500">*</span></Label>
|
||||
<DatePicker
|
||||
value={data.resignDate}
|
||||
onChange={(date) => onChange({ ...data, resignDate: date })}
|
||||
/>
|
||||
</div>
|
||||
<div className="md:col-span-2 space-y-2">
|
||||
<Label>주소</Label>
|
||||
<Input value={data.employeeAddress} disabled className="bg-gray-50" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 사직 사유 */}
|
||||
<div className="bg-white rounded-lg border p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">사직 사유</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>사유 선택 <span className="text-red-500">*</span></Label>
|
||||
<Select
|
||||
value={data.reasonType || ''}
|
||||
onValueChange={(value) => onChange({ ...data, reasonType: value })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="사유를 선택해주세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{REASON_OPTIONS.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>상세 사유</Label>
|
||||
<Textarea
|
||||
placeholder="상세 사유를 입력해주세요"
|
||||
value={data.reasonDetail}
|
||||
onChange={(e) => onChange({ ...data, reasonDetail: e.target.value })}
|
||||
className="min-h-[100px]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 제출일 */}
|
||||
<div className="bg-white rounded-lg border p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">제출 정보</h3>
|
||||
<div className="space-y-2">
|
||||
<Label>제출일</Label>
|
||||
<DatePicker
|
||||
value={data.submitDate}
|
||||
onChange={(date) => onChange({ ...data, submitDate: date })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
124
src/components/approval/DocumentCreate/SealUsageForm.tsx
Normal file
124
src/components/approval/DocumentCreate/SealUsageForm.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { DatePicker } from '@/components/ui/date-picker';
|
||||
import { format } from 'date-fns';
|
||||
import { getCompanyAutoFill } from './form-actions';
|
||||
import type { SealUsageData } from './types';
|
||||
|
||||
interface SealUsageFormProps {
|
||||
data: SealUsageData;
|
||||
onChange: (data: SealUsageData) => void;
|
||||
}
|
||||
|
||||
export function SealUsageForm({ data, onChange }: SealUsageFormProps) {
|
||||
const [isLoadingCompany, setIsLoadingCompany] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
async function loadCompanyInfo() {
|
||||
setIsLoadingCompany(true);
|
||||
const result = await getCompanyAutoFill();
|
||||
if (result.success && result.data) {
|
||||
const c = result.data;
|
||||
onChange({
|
||||
...data,
|
||||
companyName: data.companyName || c.companyName,
|
||||
businessNumber: data.businessNumber || c.businessNumber,
|
||||
address: data.address || c.address,
|
||||
representativeName: data.representativeName || c.representativeName,
|
||||
usageDate: data.usageDate || format(new Date(), 'yyyy-MM-dd'),
|
||||
});
|
||||
}
|
||||
setIsLoadingCompany(false);
|
||||
}
|
||||
if (!data.companyName) {
|
||||
loadCompanyInfo();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 1. 인감 날인 */}
|
||||
<div className="bg-white rounded-lg border p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">인감 날인</h3>
|
||||
<div className="space-y-2">
|
||||
<Label>인감 정보</Label>
|
||||
<Textarea
|
||||
placeholder="사용할 인감 정보를 입력해주세요"
|
||||
value={data.sealImprint}
|
||||
onChange={(e) => onChange({ ...data, sealImprint: e.target.value })}
|
||||
className="min-h-[80px]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 2. 사용 정보 */}
|
||||
<div className="bg-white rounded-lg border p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">사용 정보</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>용도 <span className="text-red-500">*</span></Label>
|
||||
<Input
|
||||
placeholder="사용 용도를 입력해주세요"
|
||||
value={data.purpose}
|
||||
onChange={(e) => onChange({ ...data, purpose: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>제출처 <span className="text-red-500">*</span></Label>
|
||||
<Input
|
||||
placeholder="제출처를 입력해주세요"
|
||||
value={data.submitTo}
|
||||
onChange={(e) => onChange({ ...data, submitTo: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>첨부 서류</Label>
|
||||
<Input
|
||||
placeholder="첨부 서류를 입력해주세요"
|
||||
value={data.attachedDocuments}
|
||||
onChange={(e) => onChange({ ...data, attachedDocuments: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>일자</Label>
|
||||
<DatePicker
|
||||
value={data.usageDate}
|
||||
onChange={(date) => onChange({ ...data, usageDate: date })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 3. 회사 정보 */}
|
||||
<div className="bg-white rounded-lg border p-6">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<h3 className="text-lg font-semibold">회사 정보</h3>
|
||||
{isLoadingCompany && <span className="text-sm text-gray-400">불러오는 중...</span>}
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>상호</Label>
|
||||
<Input value={data.companyName} disabled className="bg-gray-50" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>사업자번호</Label>
|
||||
<Input value={data.businessNumber} disabled className="bg-gray-50" />
|
||||
</div>
|
||||
<div className="md:col-span-2 space-y-2">
|
||||
<Label>주소</Label>
|
||||
<Input value={data.address} disabled className="bg-gray-50" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>대표이사</Label>
|
||||
<Input value={data.representativeName} disabled className="bg-gray-50" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -243,12 +243,13 @@ export async function createApproval(formData: DocumentFormData): Promise<{
|
||||
}
|
||||
|
||||
const requestBody = {
|
||||
form_code: formData.basicInfo.documentType,
|
||||
form_code: formData.basicInfo.formCode || formData.basicInfo.documentType,
|
||||
title: getDocumentTitle(formData),
|
||||
status: 'draft',
|
||||
is_urgent: formData.basicInfo.isUrgent || false,
|
||||
steps: [
|
||||
...formData.approvalLine.map((person, index) => ({
|
||||
step_type: 'approval',
|
||||
step_type: person.stepType || 'approval',
|
||||
step_order: index + 1,
|
||||
approver_id: parseInt(person.id),
|
||||
})),
|
||||
@@ -259,6 +260,7 @@ export async function createApproval(formData: DocumentFormData): Promise<{
|
||||
})),
|
||||
],
|
||||
content: getDocumentContent(formData, uploadedFiles),
|
||||
...(getDocumentBody(formData) !== undefined && { body: getDocumentBody(formData) }),
|
||||
};
|
||||
|
||||
const result = await executeServerAction<ApprovalCreateResponse>({
|
||||
@@ -365,11 +367,12 @@ export async function updateApproval(id: number, formData: DocumentFormData): Pr
|
||||
}
|
||||
|
||||
const requestBody = {
|
||||
form_code: formData.basicInfo.documentType,
|
||||
form_code: formData.basicInfo.formCode || formData.basicInfo.documentType,
|
||||
title: getDocumentTitle(formData),
|
||||
is_urgent: formData.basicInfo.isUrgent || false,
|
||||
steps: [
|
||||
...formData.approvalLine.map((person, index) => ({
|
||||
step_type: 'approval',
|
||||
step_type: person.stepType || 'approval',
|
||||
step_order: index + 1,
|
||||
approver_id: parseInt(person.id),
|
||||
})),
|
||||
@@ -380,6 +383,7 @@ export async function updateApproval(id: number, formData: DocumentFormData): Pr
|
||||
})),
|
||||
],
|
||||
content: getDocumentContent(formData, uploadedFiles),
|
||||
...(getDocumentBody(formData) !== undefined && { body: getDocumentBody(formData) }),
|
||||
};
|
||||
|
||||
const result = await executeServerAction<ApprovalCreateResponse>({
|
||||
@@ -456,9 +460,27 @@ function getDocumentTitle(formData: DocumentFormData): string {
|
||||
case 'expenseReport':
|
||||
return `지출결의서 - ${formData.expenseReportData?.requestDate || ''}`;
|
||||
case 'expenseEstimate':
|
||||
return `지출 예상 내역서`;
|
||||
return formData.expenseEstimateData?.title || '비용견적서';
|
||||
default: {
|
||||
// 전용 양식 중 title 필드가 있는 경우 활용
|
||||
const titleFromForm = formData.officialDocumentData?.title
|
||||
|| formData.quotationData?.recipientName;
|
||||
if (titleFromForm) {
|
||||
return `${formData.basicInfo.formName || '문서'} - ${titleFromForm}`;
|
||||
}
|
||||
return formData.basicInfo.formName || '문서';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 문서 본문(body) 추출 — body 필드를 사용하는 양식만 반환
|
||||
*/
|
||||
function getDocumentBody(formData: DocumentFormData): string | undefined {
|
||||
// 현재 body 필드를 사용하는 양식 없음 (비용견적서는 content로 전환됨)
|
||||
switch (formData.basicInfo.documentType) {
|
||||
default:
|
||||
return '문서';
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -494,7 +516,9 @@ function transformApiToFormData(apiData: {
|
||||
};
|
||||
title: string;
|
||||
status: string;
|
||||
is_urgent?: boolean;
|
||||
content: Record<string, unknown>;
|
||||
body?: string;
|
||||
steps?: Array<{
|
||||
step_type: string;
|
||||
approver_id: number;
|
||||
@@ -526,7 +550,26 @@ function transformApiToFormData(apiData: {
|
||||
}): DocumentFormData {
|
||||
// form.code를 우선 사용, 없으면 form_code (이전 호환성)
|
||||
const formCode = apiData.form?.code || apiData.form_code || 'proposal';
|
||||
const documentType = formCode as 'proposal' | 'expenseReport' | 'expenseEstimate';
|
||||
// API code → 프론트엔드 documentType 매핑
|
||||
const FORM_CODE_MAP: Record<string, string> = {
|
||||
'proposal': 'proposal',
|
||||
'expense_report': 'expenseReport',
|
||||
'expenseReport': 'expenseReport',
|
||||
'expense_estimate': 'expenseEstimate',
|
||||
'expenseEstimate': 'expenseEstimate',
|
||||
'official_letter': 'officialDocument',
|
||||
'resignation': 'resignation',
|
||||
'leave_promotion_1st': 'leaveNotice1st',
|
||||
'leave_promotion_2nd': 'leaveNotice2nd',
|
||||
'delegation': 'powerOfAttorney',
|
||||
'board_minutes': 'boardMinutes',
|
||||
'employment_cert': 'employmentCert',
|
||||
'career_cert': 'careerCert',
|
||||
'appointment_cert': 'appointmentCert',
|
||||
'seal_usage': 'sealUsage',
|
||||
'quotation': 'quotation',
|
||||
};
|
||||
const documentType = FORM_CODE_MAP[formCode] || formCode;
|
||||
const content = apiData.content || {};
|
||||
|
||||
// 결재선 및 참조자 분리
|
||||
@@ -550,6 +593,7 @@ function transformApiToFormData(apiData: {
|
||||
name: step.approver.name,
|
||||
position,
|
||||
department,
|
||||
stepType: (step.step_type === 'agreement' ? 'agreement' : 'approval') as ApprovalPerson['stepType'],
|
||||
};
|
||||
|
||||
// 'approval'과 'agreement' 모두 결재선에 포함
|
||||
@@ -573,6 +617,11 @@ function transformApiToFormData(apiData: {
|
||||
draftDate: apiData.created_at,
|
||||
documentNo: apiData.document_number,
|
||||
documentType,
|
||||
formId: apiData.form?.id,
|
||||
formCode: apiData.form?.code || formCode,
|
||||
formName: apiData.form?.name,
|
||||
formCategory: apiData.form?.category,
|
||||
isUrgent: apiData.is_urgent || false,
|
||||
};
|
||||
|
||||
// 기존 업로드 파일 추출
|
||||
@@ -645,8 +694,10 @@ function transformApiToFormData(apiData: {
|
||||
}>) || [];
|
||||
|
||||
expenseEstimateData = {
|
||||
items: items.map(item => ({
|
||||
id: item.id,
|
||||
title: (apiData.title as string) || '',
|
||||
body: (apiData.body as string) || '',
|
||||
items: items.map((item, idx) => ({
|
||||
id: item.id || `restored-${idx}-${Date.now()}`,
|
||||
checked: item.checked || false,
|
||||
expectedPaymentDate: item.expectedPaymentDate,
|
||||
category: item.category,
|
||||
@@ -660,6 +711,25 @@ function transformApiToFormData(apiData: {
|
||||
};
|
||||
}
|
||||
|
||||
// 12개 전용 양식 데이터: content에서 각 전용 폼 데이터로 매핑
|
||||
const DEDICATED_FORM_TYPES = [
|
||||
'officialDocument', 'resignation', 'employmentCert', 'careerCert',
|
||||
'appointmentCert', 'sealUsage', 'leaveNotice1st', 'leaveNotice2nd',
|
||||
'powerOfAttorney', 'boardMinutes', 'quotation',
|
||||
];
|
||||
const isDedicatedBuiltin = ['proposal', 'expenseReport', 'expenseEstimate'].includes(documentType);
|
||||
const isDedicatedNew = DEDICATED_FORM_TYPES.includes(documentType);
|
||||
|
||||
// 동적 폼 데이터: 전용 폼이 아닌 경우에만 content를 그대로 사용
|
||||
const dynamicFormData = (isDedicatedBuiltin || isDedicatedNew) ? undefined : content;
|
||||
|
||||
// 전용 양식별 데이터 복원
|
||||
const dedicatedFormDataResult: Record<string, unknown> = {};
|
||||
if (isDedicatedNew && Object.keys(content).length > 0) {
|
||||
const key = `${documentType}Data`;
|
||||
dedicatedFormDataResult[key] = content;
|
||||
}
|
||||
|
||||
return {
|
||||
basicInfo,
|
||||
approvalLine,
|
||||
@@ -667,6 +737,8 @@ function transformApiToFormData(apiData: {
|
||||
proposalData,
|
||||
expenseReportData,
|
||||
expenseEstimateData,
|
||||
dynamicFormData,
|
||||
...dedicatedFormDataResult,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -708,12 +780,36 @@ function getDocumentContent(
|
||||
};
|
||||
case 'expenseEstimate':
|
||||
return {
|
||||
items: formData.expenseEstimateData?.items,
|
||||
items: formData.expenseEstimateData?.items?.map(item => ({
|
||||
expectedPaymentDate: item.expectedPaymentDate,
|
||||
category: item.category,
|
||||
amount: item.amount,
|
||||
vendor: item.vendor,
|
||||
memo: item.memo,
|
||||
})),
|
||||
totalExpense: formData.expenseEstimateData?.totalExpense,
|
||||
accountBalance: formData.expenseEstimateData?.accountBalance,
|
||||
finalDifference: formData.expenseEstimateData?.finalDifference,
|
||||
};
|
||||
default:
|
||||
return {};
|
||||
default: {
|
||||
// 12개 전용 양식: 해당 폼 데이터를 content로 직렬화
|
||||
const dedicatedFormDataMap: Record<string, unknown> = {
|
||||
officialDocument: formData.officialDocumentData,
|
||||
resignation: formData.resignationData,
|
||||
employmentCert: formData.employmentCertData,
|
||||
careerCert: formData.careerCertData,
|
||||
appointmentCert: formData.appointmentCertData,
|
||||
sealUsage: formData.sealUsageData,
|
||||
leaveNotice1st: formData.leaveNotice1stData,
|
||||
leaveNotice2nd: formData.leaveNotice2ndData,
|
||||
powerOfAttorney: formData.powerOfAttorneyData,
|
||||
boardMinutes: formData.boardMinutesData,
|
||||
quotation: formData.quotationData,
|
||||
};
|
||||
const dedicatedData = dedicatedFormDataMap[formData.basicInfo.documentType];
|
||||
if (dedicatedData) return dedicatedData as Record<string, unknown>;
|
||||
// 동적 폼 데이터
|
||||
return formData.dynamicFormData || {};
|
||||
}
|
||||
}
|
||||
}
|
||||
299
src/components/approval/DocumentCreate/form-actions.ts
Normal file
299
src/components/approval/DocumentCreate/form-actions.ts
Normal file
@@ -0,0 +1,299 @@
|
||||
/**
|
||||
* 결재 양식(approval-forms) 서버 액션
|
||||
*
|
||||
* API Endpoints:
|
||||
* - GET /api/v1/approval-forms - 양식 목록 (active)
|
||||
* - GET /api/v1/approval-forms/{id} - 양식 상세 (template 포함)
|
||||
*/
|
||||
|
||||
'use server';
|
||||
|
||||
import { executeServerAction } from '@/lib/api/execute-server-action';
|
||||
import { buildApiUrl } from '@/lib/api/query-params';
|
||||
|
||||
// ============================================
|
||||
// 타입 정의
|
||||
// ============================================
|
||||
|
||||
export interface ApprovalFormItem {
|
||||
id: number;
|
||||
name: string;
|
||||
code: string;
|
||||
category: string;
|
||||
}
|
||||
|
||||
export interface ApprovalFormField {
|
||||
name: string;
|
||||
type: 'text' | 'textarea' | 'number' | 'date' | 'select' | 'checkbox' | 'file' | 'daterange' | 'array';
|
||||
label: string;
|
||||
required?: boolean;
|
||||
options?: string[];
|
||||
placeholder?: string;
|
||||
description?: string;
|
||||
columns?: ApprovalFormField[]; // array 타입의 하위 필드
|
||||
}
|
||||
|
||||
export interface ApprovalFormDetail {
|
||||
id: number;
|
||||
name: string;
|
||||
code: string;
|
||||
category: string;
|
||||
template: {
|
||||
fields: ApprovalFormField[];
|
||||
};
|
||||
}
|
||||
|
||||
// category별 그룹 (2단계 Select용)
|
||||
export interface ApprovalFormCategory {
|
||||
category: string;
|
||||
categoryLabel: string;
|
||||
forms: ApprovalFormItem[];
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 공통 자동입력 데이터 타입
|
||||
// ============================================
|
||||
|
||||
export interface CompanyAutoFill {
|
||||
companyName: string;
|
||||
representativeName: string;
|
||||
businessNumber: string;
|
||||
address: string;
|
||||
phone: string;
|
||||
fax: string;
|
||||
email: string;
|
||||
businessType: string;
|
||||
businessCategory: string;
|
||||
}
|
||||
|
||||
export interface EmployeeAutoFill {
|
||||
id: string;
|
||||
name: string;
|
||||
department: string;
|
||||
position: string;
|
||||
residentNumber: string;
|
||||
birthDate: string;
|
||||
address: string;
|
||||
phone: string;
|
||||
joinDate: string;
|
||||
}
|
||||
|
||||
export interface LeaveBalanceAutoFill {
|
||||
totalDays: number;
|
||||
usedDays: number;
|
||||
remainingDays: number;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// API 함수
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* 활성 양식 목록 조회
|
||||
*/
|
||||
export async function getApprovalForms(): Promise<{ success: boolean; data: ApprovalFormItem[]; error?: string }> {
|
||||
const result = await executeServerAction<ApprovalFormItem[] | { data: ApprovalFormItem[] }>({
|
||||
url: buildApiUrl('/api/v1/approval-forms'),
|
||||
errorMessage: '양식 목록 조회에 실패했습니다.',
|
||||
});
|
||||
|
||||
if (!result.success) return { success: false, data: [], error: result.error };
|
||||
|
||||
// API 응답이 배열 또는 { data: [...] } 형태 모두 처리
|
||||
const rawData = result.data;
|
||||
let forms: ApprovalFormItem[] = [];
|
||||
if (Array.isArray(rawData)) {
|
||||
forms = rawData;
|
||||
} else if (rawData && typeof rawData === 'object' && 'data' in rawData && Array.isArray(rawData.data)) {
|
||||
forms = rawData.data;
|
||||
}
|
||||
|
||||
return { success: true, data: forms };
|
||||
}
|
||||
|
||||
/**
|
||||
* 양식 상세 조회 (template.fields 포함)
|
||||
*/
|
||||
export async function getApprovalFormDetail(id: number): Promise<{ success: boolean; data?: ApprovalFormDetail; error?: string }> {
|
||||
const result = await executeServerAction<ApprovalFormDetail>({
|
||||
url: buildApiUrl(`/api/v1/approval-forms/${id}`),
|
||||
errorMessage: '양식 상세 조회에 실패했습니다.',
|
||||
});
|
||||
|
||||
if (!result.success) return { success: false, error: result.error };
|
||||
return { success: true, data: result.data || undefined };
|
||||
}
|
||||
|
||||
/**
|
||||
* 양식 목록을 카테고리별로 그룹핑 (2단계 Select용)
|
||||
*/
|
||||
export async function getApprovalFormsByCategory(): Promise<{ success: boolean; data: ApprovalFormCategory[]; error?: string }> {
|
||||
const result = await getApprovalForms();
|
||||
if (!result.success) return { success: false, data: [], error: result.error };
|
||||
|
||||
const categoryMap = new Map<string, ApprovalFormItem[]>();
|
||||
for (const form of result.data) {
|
||||
const existing = categoryMap.get(form.category) || [];
|
||||
existing.push(form);
|
||||
categoryMap.set(form.category, existing);
|
||||
}
|
||||
|
||||
const CATEGORY_LABELS: Record<string, string> = {
|
||||
'일반': '일반',
|
||||
'경비': '경비',
|
||||
'hr': '인사',
|
||||
'general': '총무',
|
||||
'expense': '재무',
|
||||
'request': '총무/기타',
|
||||
'certificate': '증명서',
|
||||
'finance': '재무',
|
||||
'document': '문서',
|
||||
'proposal': '품의',
|
||||
'expense_report': '지출',
|
||||
'expense_estimate': '예산',
|
||||
};
|
||||
|
||||
const data: ApprovalFormCategory[] = Array.from(categoryMap.entries()).map(([category, forms]) => ({
|
||||
category,
|
||||
categoryLabel: CATEGORY_LABELS[category] || category,
|
||||
forms,
|
||||
}));
|
||||
|
||||
return { success: true, data };
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 공통 자동입력 함수 (전용 폼에서 사용)
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* 회사 정보 자동입력용 조회
|
||||
*/
|
||||
export async function getCompanyAutoFill(): Promise<{ success: boolean; data?: CompanyAutoFill; error?: string }> {
|
||||
const result = await executeServerAction<{
|
||||
company_name?: string;
|
||||
representative_name?: string;
|
||||
business_number?: string;
|
||||
address?: string;
|
||||
phone?: string;
|
||||
fax?: string;
|
||||
email?: string;
|
||||
business_type?: string;
|
||||
business_category?: string;
|
||||
[key: string]: unknown;
|
||||
}>({
|
||||
url: buildApiUrl('/api/v1/tenant/company-info'),
|
||||
errorMessage: '회사 정보 조회에 실패했습니다.',
|
||||
});
|
||||
|
||||
if (!result.success || !result.data) return { success: false, error: result.error };
|
||||
|
||||
const d = result.data;
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
companyName: d.company_name || '',
|
||||
representativeName: d.representative_name || '',
|
||||
businessNumber: d.business_number || '',
|
||||
address: d.address || '',
|
||||
phone: d.phone || '',
|
||||
fax: d.fax || '',
|
||||
email: d.email || '',
|
||||
businessType: d.business_type || '',
|
||||
businessCategory: d.business_category || '',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 직원 상세 정보 자동입력용 조회
|
||||
*/
|
||||
export async function getEmployeeAutoFill(employeeId: string): Promise<{ success: boolean; data?: EmployeeAutoFill; error?: string }> {
|
||||
const result = await executeServerAction<{
|
||||
id?: number;
|
||||
user?: { id?: number; name?: string };
|
||||
department?: { id?: number; name?: string } | null;
|
||||
position?: { id?: number; name?: string; key?: string } | null;
|
||||
resident_number?: string;
|
||||
birth_date?: string;
|
||||
address?: string;
|
||||
phone?: string;
|
||||
hire_date?: string;
|
||||
[key: string]: unknown;
|
||||
}>({
|
||||
url: buildApiUrl(`/api/v1/employees/${employeeId}`),
|
||||
errorMessage: '직원 정보 조회에 실패했습니다.',
|
||||
});
|
||||
|
||||
if (!result.success || !result.data) return { success: false, error: result.error };
|
||||
|
||||
const d = result.data;
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
id: String(d.user?.id || d.id || ''),
|
||||
name: d.user?.name || '',
|
||||
department: d.department?.name || '',
|
||||
position: d.position?.name || '',
|
||||
residentNumber: d.resident_number || '',
|
||||
birthDate: d.birth_date || '',
|
||||
address: d.address || '',
|
||||
phone: d.phone || '',
|
||||
joinDate: d.hire_date || '',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 직원 목록 조회 (Select 옵션용)
|
||||
*/
|
||||
export async function getEmployeeOptions(): Promise<{ success: boolean; data: { id: string; name: string; department: string }[]; error?: string }> {
|
||||
const result = await executeServerAction<{
|
||||
data?: Array<{
|
||||
id: number;
|
||||
user?: { id?: number; name?: string };
|
||||
department?: { name?: string } | null;
|
||||
}>;
|
||||
}>({
|
||||
url: buildApiUrl('/api/v1/employees', { per_page: 200, status: 'active' }),
|
||||
errorMessage: '직원 목록 조회에 실패했습니다.',
|
||||
});
|
||||
|
||||
if (!result.success || !result.data) return { success: false, data: [], error: result.error };
|
||||
|
||||
const employees = (result.data.data || []).map((e) => ({
|
||||
id: String(e.id),
|
||||
name: e.user?.name || '',
|
||||
department: e.department?.name || '',
|
||||
}));
|
||||
|
||||
return { success: true, data: employees };
|
||||
}
|
||||
|
||||
/**
|
||||
* 직원 연차 잔여일수 조회
|
||||
*/
|
||||
export async function getLeaveBalanceAutoFill(userId: number, year?: number): Promise<{ success: boolean; data?: LeaveBalanceAutoFill; error?: string }> {
|
||||
const y = year || new Date().getFullYear();
|
||||
const result = await executeServerAction<{
|
||||
total_days?: number;
|
||||
used_days?: number;
|
||||
remaining_days?: number;
|
||||
[key: string]: unknown;
|
||||
}>({
|
||||
url: buildApiUrl(`/api/v1/leaves/balance/${userId}`, { year: y }),
|
||||
errorMessage: '연차 잔여일수 조회에 실패했습니다.',
|
||||
});
|
||||
|
||||
if (!result.success || !result.data) return { success: false, error: result.error };
|
||||
|
||||
const d = result.data;
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
totalDays: d.total_days || 0,
|
||||
usedDays: d.used_days || 0,
|
||||
remainingDays: d.remaining_days || 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback, useEffect, useTransition, useRef, useMemo } from 'react';
|
||||
import { useState, useCallback, useEffect, useRef, useMemo } from 'react';
|
||||
import { invalidateDashboard } from '@/lib/dashboard-invalidation';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { usePermission } from '@/hooks/usePermission';
|
||||
@@ -31,12 +31,33 @@ import { ReferenceSection } from './ReferenceSection';
|
||||
import { ProposalForm } from './ProposalForm';
|
||||
import { ExpenseReportForm } from './ExpenseReportForm';
|
||||
import { ExpenseEstimateForm } from './ExpenseEstimateForm';
|
||||
import { DocumentDetailModal } from '@/components/approval/DocumentDetail';
|
||||
import { OfficialDocumentForm } from './OfficialDocumentForm';
|
||||
import { ResignationForm } from './ResignationForm';
|
||||
import { LeaveNotice1stForm } from './LeaveNotice1stForm';
|
||||
import { LeaveNotice2ndForm } from './LeaveNotice2ndForm';
|
||||
import { PowerOfAttorneyForm } from './PowerOfAttorneyForm';
|
||||
import { BoardMinutesForm } from './BoardMinutesForm';
|
||||
import { EmploymentCertForm } from './EmploymentCertForm';
|
||||
import { CareerCertForm } from './CareerCertForm';
|
||||
import { AppointmentCertForm } from './AppointmentCertForm';
|
||||
import { SealUsageForm } from './SealUsageForm';
|
||||
import { QuotationForm } from './QuotationForm';
|
||||
import { DynamicFormRenderer } from './DynamicFormRenderer';
|
||||
import type { ApprovalFormField } from './form-actions';
|
||||
import { DEDICATED_FORM_CODES } from './types';
|
||||
|
||||
function isDedicatedForm(docType: string): boolean {
|
||||
return (DEDICATED_FORM_CODES as readonly string[]).includes(docType);
|
||||
}
|
||||
// V2: DynamicDocument 렌더링 지원 (14개 전용 양식 미리보기)
|
||||
import { DocumentDetailModalV2 as DocumentDetailModal } from '@/components/approval/DocumentDetail';
|
||||
import { getFieldLabels, filterVisibleFields } from '@/components/approval/DocumentDetail/field-labels';
|
||||
import type {
|
||||
DocumentType as ModalDocumentType,
|
||||
ProposalDocumentData,
|
||||
ExpenseReportDocumentData,
|
||||
ExpenseEstimateDocumentData,
|
||||
DynamicDocumentData,
|
||||
} from '@/components/approval/DocumentDetail/types';
|
||||
import type {
|
||||
BasicInfo,
|
||||
@@ -44,6 +65,17 @@ import type {
|
||||
ProposalData,
|
||||
ExpenseReportData,
|
||||
ExpenseEstimateData,
|
||||
OfficialDocumentData,
|
||||
ResignationData,
|
||||
EmploymentCertData,
|
||||
CareerCertData,
|
||||
AppointmentCertData,
|
||||
SealUsageData,
|
||||
LeaveNotice1stData,
|
||||
LeaveNotice2ndData,
|
||||
PowerOfAttorneyData,
|
||||
BoardMinutesData,
|
||||
QuotationData,
|
||||
} from './types';
|
||||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||
import { useDevFill, generatePurchaseApprovalData } from '@/components/dev';
|
||||
@@ -78,6 +110,8 @@ const getInitialExpenseReportData = (): ExpenseReportData => ({
|
||||
});
|
||||
|
||||
const getInitialExpenseEstimateData = (): ExpenseEstimateData => ({
|
||||
title: '',
|
||||
body: '',
|
||||
items: [],
|
||||
totalExpense: 0,
|
||||
accountBalance: 10000000,
|
||||
@@ -87,18 +121,22 @@ const getInitialExpenseEstimateData = (): ExpenseEstimateData => ({
|
||||
export function DocumentCreate() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const currentUser = useAuthStore((state) => state.currentUser);
|
||||
const { canCreate, canDelete } = usePermission();
|
||||
|
||||
// 수정 모드 / 복제 모드 상태
|
||||
const documentId = searchParams.get('id');
|
||||
const urlDocumentId = searchParams.get('id');
|
||||
const mode = searchParams.get('mode');
|
||||
const copyFromId = searchParams.get('copyFrom');
|
||||
const isEditMode = mode === 'edit' && !!documentId;
|
||||
const isCopyMode = !!copyFromId;
|
||||
const [isLoadingDocument, setIsLoadingDocument] = useState(false);
|
||||
|
||||
// BUG #11 fix: 임시저장 후 edit 모드 전환을 위한 상태
|
||||
const [savedDocId, setSavedDocId] = useState<string | null>(null);
|
||||
const documentId = urlDocumentId || savedDocId;
|
||||
const isEditMode = (mode === 'edit' && !!urlDocumentId) || !!savedDocId;
|
||||
|
||||
// 상태 관리
|
||||
const [basicInfo, setBasicInfo] = useState<BasicInfo>(getInitialBasicInfo);
|
||||
const [approvalLine, setApprovalLine] = useState<ApprovalPerson[]>([]);
|
||||
@@ -106,7 +144,29 @@ export function DocumentCreate() {
|
||||
const [proposalData, setProposalData] = useState<ProposalData>(getInitialProposalData);
|
||||
const [expenseReportData, setExpenseReportData] = useState<ExpenseReportData>(getInitialExpenseReportData);
|
||||
const [expenseEstimateData, setExpenseEstimateData] = useState<ExpenseEstimateData>(getInitialExpenseEstimateData);
|
||||
const [dynamicFormData, setDynamicFormData] = useState<Record<string, unknown>>({});
|
||||
const [dynamicFormFields, setDynamicFormFields] = useState<ApprovalFormField[]>([]);
|
||||
const [officialDocumentData, setOfficialDocumentData] = useState<OfficialDocumentData>({ documentNumber: '', documentDate: '', recipient: '', reference: '', title: '', body: '', attachment: '', companyName: '', representativeName: '', address: '', phone: '', fax: '', email: '' });
|
||||
const [resignationData, setResignationData] = useState<ResignationData>({ employeeId: '', department: '', position: '', employeeName: '', residentNumber: '', joinDate: '', resignDate: '', employeeAddress: '', reasonType: '', reasonDetail: '', submitDate: '' });
|
||||
const [employmentCertData, setEmploymentCertData] = useState<EmploymentCertData>({ employeeId: '', employeeName: '', residentNumber: '', employeeAddress: '', companyName: '', businessNumber: '', department: '', position: '', employmentPeriodStart: '', employmentPeriodEnd: '', purpose: '', issueDate: '' });
|
||||
const [careerCertData, setCareerCertData] = useState<CareerCertData>({ employeeId: '', employeeName: '', residentNumber: '', birthDate: '', employeeAddress: '', companyName: '', businessNumber: '', representativeName: '', companyPhone: '', companyAddress: '', department: '', positionTitle: '', workPeriodStart: '', workPeriodEnd: '', duties: '', purpose: '', issueDate: '' });
|
||||
const [appointmentCertData, setAppointmentCertData] = useState<AppointmentCertData>({ employeeId: '', employeeName: '', residentNumber: '', department: '', phone: '', appointmentPeriodStart: '', appointmentPeriodEnd: '', contractQualification: '', purpose: '', issueDate: '' });
|
||||
const [sealUsageData, setSealUsageData] = useState<SealUsageData>({ sealImprint: '', purpose: '', submitTo: '', attachedDocuments: '', usageDate: '', companyName: '', businessNumber: '', address: '', representativeName: '' });
|
||||
const [leaveNotice1stData, setLeaveNotice1stData] = useState<LeaveNotice1stData>({ employeeId: '', department: '', position: '', totalDays: 0, usedDays: 0, remainingDays: 0, deadline: '', legalNotice: '' });
|
||||
const [leaveNotice2ndData, setLeaveNotice2ndData] = useState<LeaveNotice2ndData>({ employeeId: '', department: '', position: '', remainingDays: 0, designatedDates: [''], legalNotice: '' });
|
||||
const [powerOfAttorneyData, setPowerOfAttorneyData] = useState<PowerOfAttorneyData>({ principalCompanyName: '', principalBusinessNumber: '', principalAddress: '', principalRepresentative: '', agentName: '', agentBirthDate: '', agentAddress: '', agentPhone: '', agentDepartment: '', delegationDetails: '', delegationPeriodStart: '', delegationPeriodEnd: '', attachedDocuments: '' });
|
||||
const [boardMinutesData, setBoardMinutesData] = useState<BoardMinutesData>({ meetingDate: '', meetingPlace: '', totalDirectors: 0, attendingDirectors: 0, totalAuditors: 0, attendingAuditors: 0, agendaTitle: '', agendaResult: '', chairperson: '', proceedings: '', adjournmentTime: '', signatories: '' });
|
||||
const [quotationData, setQuotationData] = useState<QuotationData>({ recipientName: '', quotationDate: '', businessNumber: '', companyName: '', representativeName: '', companyAddress: '', businessType: '', businessCategory: '', companyPhone: '', accountNumber: '', autoTax: true, items: [], specialNotes: '' });
|
||||
const [isLoadingEstimate, setIsLoadingEstimate] = useState(false);
|
||||
const prevFormIdRef = useRef<number | undefined>(undefined);
|
||||
|
||||
// 양식 변경 시 동적 폼 데이터 초기화
|
||||
useEffect(() => {
|
||||
if (prevFormIdRef.current !== undefined && basicInfo.formId !== prevFormIdRef.current) {
|
||||
setDynamicFormData({});
|
||||
}
|
||||
prevFormIdRef.current = basicInfo.formId;
|
||||
}, [basicInfo.formId]);
|
||||
|
||||
// 복제 모드 toast 중복 호출 방지
|
||||
const copyToastShownRef = useRef(false);
|
||||
@@ -210,23 +270,39 @@ export function DocumentCreate() {
|
||||
}
|
||||
}, [isEditMode, isCopyMode, currentUser?.name]));
|
||||
|
||||
// 수정 모드: 문서 로드
|
||||
// 수정 모드: 문서 로드 (URL 기반으로만 — savedDocId로 전환 시에는 재로딩하지 않음)
|
||||
useEffect(() => {
|
||||
if (!isEditMode || !documentId) return;
|
||||
if (!(mode === 'edit' && urlDocumentId)) return;
|
||||
|
||||
const loadDocument = async () => {
|
||||
setIsLoadingDocument(true);
|
||||
try {
|
||||
const result = await getApprovalById(parseInt(documentId));
|
||||
const result = await getApprovalById(parseInt(urlDocumentId));
|
||||
if (result.success && result.data) {
|
||||
const { basicInfo: loadedBasicInfo, approvalLine: loadedApprovalLine, references: loadedReferences, proposalData: loadedProposalData, expenseReportData: loadedExpenseReportData, expenseEstimateData: loadedExpenseEstimateData } = result.data;
|
||||
const loaded = result.data;
|
||||
|
||||
setBasicInfo(loadedBasicInfo);
|
||||
setApprovalLine(loadedApprovalLine);
|
||||
setReferences(loadedReferences);
|
||||
if (loadedProposalData) setProposalData(loadedProposalData);
|
||||
if (loadedExpenseReportData) setExpenseReportData(loadedExpenseReportData);
|
||||
if (loadedExpenseEstimateData) setExpenseEstimateData(loadedExpenseEstimateData);
|
||||
setBasicInfo(loaded.basicInfo);
|
||||
setApprovalLine(loaded.approvalLine);
|
||||
setReferences(loaded.references);
|
||||
if (loaded.proposalData) setProposalData(loaded.proposalData);
|
||||
if (loaded.expenseReportData) setExpenseReportData(loaded.expenseReportData);
|
||||
if (loaded.expenseEstimateData) setExpenseEstimateData(loaded.expenseEstimateData);
|
||||
if (loaded.dynamicFormData) setDynamicFormData(loaded.dynamicFormData);
|
||||
|
||||
// 12개 전용 양식 데이터 복원
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const d = loaded as any;
|
||||
if (d.officialDocumentData) setOfficialDocumentData(d.officialDocumentData);
|
||||
if (d.resignationData) setResignationData(d.resignationData);
|
||||
if (d.employmentCertData) setEmploymentCertData(d.employmentCertData);
|
||||
if (d.careerCertData) setCareerCertData(d.careerCertData);
|
||||
if (d.appointmentCertData) setAppointmentCertData(d.appointmentCertData);
|
||||
if (d.sealUsageData) setSealUsageData(d.sealUsageData);
|
||||
if (d.leaveNotice1stData) setLeaveNotice1stData(d.leaveNotice1stData);
|
||||
if (d.leaveNotice2ndData) setLeaveNotice2ndData(d.leaveNotice2ndData);
|
||||
if (d.powerOfAttorneyData) setPowerOfAttorneyData(d.powerOfAttorneyData);
|
||||
if (d.boardMinutesData) setBoardMinutesData(d.boardMinutesData);
|
||||
if (d.quotationData) setQuotationData(d.quotationData);
|
||||
} else {
|
||||
toast.error(result.error || '문서를 불러오는데 실패했습니다.');
|
||||
router.back();
|
||||
@@ -242,7 +318,7 @@ export function DocumentCreate() {
|
||||
};
|
||||
|
||||
loadDocument();
|
||||
}, [isEditMode, documentId, router]);
|
||||
}, [mode, urlDocumentId, router]);
|
||||
|
||||
// 복제 모드: 원본 문서 로드 후 새 문서로 설정
|
||||
useEffect(() => {
|
||||
@@ -253,24 +329,40 @@ export function DocumentCreate() {
|
||||
try {
|
||||
const result = await getApprovalById(parseInt(copyFromId));
|
||||
if (result.success && result.data) {
|
||||
const { basicInfo: loadedBasicInfo, approvalLine: loadedApprovalLine, references: loadedReferences, proposalData: loadedProposalData, expenseReportData: loadedExpenseReportData, expenseEstimateData: loadedExpenseEstimateData } = result.data;
|
||||
const loaded = result.data;
|
||||
|
||||
// 복제: 문서번호 초기화, 기안일 현재 시간으로
|
||||
const now = format(new Date(), 'yyyy-MM-dd HH:mm');
|
||||
setBasicInfo({
|
||||
...loadedBasicInfo,
|
||||
...loaded.basicInfo,
|
||||
documentNo: '', // 새 문서이므로 문서번호 초기화
|
||||
draftDate: now,
|
||||
});
|
||||
|
||||
// 결재선/참조는 그대로 유지
|
||||
setApprovalLine(loadedApprovalLine);
|
||||
setReferences(loadedReferences);
|
||||
setApprovalLine(loaded.approvalLine);
|
||||
setReferences(loaded.references);
|
||||
|
||||
// 문서 내용 복제
|
||||
if (loadedProposalData) setProposalData(loadedProposalData);
|
||||
if (loadedExpenseReportData) setExpenseReportData(loadedExpenseReportData);
|
||||
if (loadedExpenseEstimateData) setExpenseEstimateData(loadedExpenseEstimateData);
|
||||
if (loaded.proposalData) setProposalData(loaded.proposalData);
|
||||
if (loaded.expenseReportData) setExpenseReportData(loaded.expenseReportData);
|
||||
if (loaded.expenseEstimateData) setExpenseEstimateData(loaded.expenseEstimateData);
|
||||
if (loaded.dynamicFormData) setDynamicFormData(loaded.dynamicFormData);
|
||||
|
||||
// 12개 전용 양식 데이터 복원
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const d = loaded as any;
|
||||
if (d.officialDocumentData) setOfficialDocumentData(d.officialDocumentData);
|
||||
if (d.resignationData) setResignationData(d.resignationData);
|
||||
if (d.employmentCertData) setEmploymentCertData(d.employmentCertData);
|
||||
if (d.careerCertData) setCareerCertData(d.careerCertData);
|
||||
if (d.appointmentCertData) setAppointmentCertData(d.appointmentCertData);
|
||||
if (d.sealUsageData) setSealUsageData(d.sealUsageData);
|
||||
if (d.leaveNotice1stData) setLeaveNotice1stData(d.leaveNotice1stData);
|
||||
if (d.leaveNotice2ndData) setLeaveNotice2ndData(d.leaveNotice2ndData);
|
||||
if (d.powerOfAttorneyData) setPowerOfAttorneyData(d.powerOfAttorneyData);
|
||||
if (d.boardMinutesData) setBoardMinutesData(d.boardMinutesData);
|
||||
if (d.quotationData) setQuotationData(d.quotationData);
|
||||
|
||||
// React.StrictMode에서 useEffect 두 번 실행으로 인한 toast 중복 방지
|
||||
if (!copyToastShownRef.current) {
|
||||
@@ -300,12 +392,13 @@ export function DocumentCreate() {
|
||||
try {
|
||||
const result = await getExpenseEstimateItems();
|
||||
if (result) {
|
||||
setExpenseEstimateData({
|
||||
setExpenseEstimateData(prev => ({
|
||||
...prev,
|
||||
items: result.items,
|
||||
totalExpense: result.totalExpense,
|
||||
accountBalance: result.accountBalance,
|
||||
finalDifference: result.finalDifference,
|
||||
});
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
@@ -325,15 +418,28 @@ export function DocumentCreate() {
|
||||
|
||||
// 폼 데이터 수집
|
||||
const getFormData = useCallback(() => {
|
||||
const dt = basicInfo.documentType;
|
||||
return {
|
||||
basicInfo,
|
||||
approvalLine,
|
||||
references,
|
||||
proposalData: basicInfo.documentType === 'proposal' ? proposalData : undefined,
|
||||
expenseReportData: basicInfo.documentType === 'expenseReport' ? expenseReportData : undefined,
|
||||
expenseEstimateData: basicInfo.documentType === 'expenseEstimate' ? expenseEstimateData : undefined,
|
||||
proposalData: dt === 'proposal' ? proposalData : undefined,
|
||||
expenseReportData: dt === 'expenseReport' ? expenseReportData : undefined,
|
||||
expenseEstimateData: dt === 'expenseEstimate' ? expenseEstimateData : undefined,
|
||||
officialDocumentData: dt === 'officialDocument' ? officialDocumentData : undefined,
|
||||
resignationData: dt === 'resignation' ? resignationData : undefined,
|
||||
employmentCertData: dt === 'employmentCert' ? employmentCertData : undefined,
|
||||
careerCertData: dt === 'careerCert' ? careerCertData : undefined,
|
||||
appointmentCertData: dt === 'appointmentCert' ? appointmentCertData : undefined,
|
||||
sealUsageData: dt === 'sealUsage' ? sealUsageData : undefined,
|
||||
leaveNotice1stData: dt === 'leaveNotice1st' ? leaveNotice1stData : undefined,
|
||||
leaveNotice2ndData: dt === 'leaveNotice2nd' ? leaveNotice2ndData : undefined,
|
||||
powerOfAttorneyData: dt === 'powerOfAttorney' ? powerOfAttorneyData : undefined,
|
||||
boardMinutesData: dt === 'boardMinutes' ? boardMinutesData : undefined,
|
||||
quotationData: dt === 'quotation' ? quotationData : undefined,
|
||||
dynamicFormData: isDedicatedForm(dt) ? undefined : dynamicFormData,
|
||||
};
|
||||
}, [basicInfo, approvalLine, references, proposalData, expenseReportData, expenseEstimateData]);
|
||||
}, [basicInfo, approvalLine, references, proposalData, expenseReportData, expenseEstimateData, officialDocumentData, resignationData, employmentCertData, careerCertData, appointmentCertData, sealUsageData, leaveNotice1stData, leaveNotice2ndData, powerOfAttorneyData, boardMinutesData, quotationData, dynamicFormData]);
|
||||
|
||||
// 핸들러
|
||||
const handleBack = useCallback(() => {
|
||||
@@ -349,22 +455,23 @@ export function DocumentCreate() {
|
||||
|
||||
// 수정 모드: 실제 문서 삭제
|
||||
if (isEditMode && documentId) {
|
||||
startTransition(async () => {
|
||||
try {
|
||||
const result = await deleteApproval(parseInt(documentId));
|
||||
if (result.success) {
|
||||
invalidateDashboard('approval');
|
||||
toast.success('문서가 삭제되었습니다.');
|
||||
router.back();
|
||||
} else {
|
||||
toast.error(result.error || '문서 삭제에 실패했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('Delete error:', error);
|
||||
toast.error('문서 삭제 중 오류가 발생했습니다.');
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const result = await deleteApproval(parseInt(documentId));
|
||||
if (result.success) {
|
||||
invalidateDashboard('approval');
|
||||
toast.success('문서가 삭제되었습니다.');
|
||||
router.back();
|
||||
} else {
|
||||
toast.error(result.error || '문서 삭제에 실패했습니다.');
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('Delete error:', error);
|
||||
toast.error('문서 삭제 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
} else {
|
||||
// 새 문서: 그냥 뒤로가기
|
||||
router.back();
|
||||
@@ -373,86 +480,135 @@ export function DocumentCreate() {
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
// 유효성 검사
|
||||
if (!basicInfo.formId) {
|
||||
toast.error('양식을 선택해주세요.');
|
||||
return;
|
||||
}
|
||||
if (approvalLine.length === 0) {
|
||||
toast.error('결재선을 지정해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
startTransition(async () => {
|
||||
try {
|
||||
const formData = getFormData();
|
||||
|
||||
// 수정 모드: 수정 후 상신
|
||||
if (isEditMode && documentId) {
|
||||
const result = await updateAndSubmitApproval(parseInt(documentId), formData);
|
||||
if (result.success) {
|
||||
invalidateDashboard('approval');
|
||||
toast.success('수정 및 상신 완료', {
|
||||
description: `문서번호: ${result.data?.documentNo}`,
|
||||
});
|
||||
router.back();
|
||||
} else {
|
||||
toast.error(result.error || '문서 상신에 실패했습니다.');
|
||||
}
|
||||
} else {
|
||||
// 새 문서: 생성 후 상신
|
||||
const result = await createAndSubmitApproval(formData);
|
||||
if (result.success) {
|
||||
invalidateDashboard('approval');
|
||||
toast.success('상신 완료', {
|
||||
description: `문서번호: ${result.data?.documentNo}`,
|
||||
});
|
||||
router.back();
|
||||
} else {
|
||||
toast.error(result.error || '문서 상신에 실패했습니다.');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('Submit error:', error);
|
||||
toast.error('문서 상신 중 오류가 발생했습니다.');
|
||||
// BUG #14 fix: 동적 폼 필수 필드 프론트엔드 검증
|
||||
if (dynamicFormFields.length > 0) {
|
||||
const requiredFields = dynamicFormFields.filter(f => f.required);
|
||||
const missingFields = requiredFields.filter(f => {
|
||||
const val = dynamicFormData[f.name];
|
||||
return val === '' || val === null || val === undefined;
|
||||
});
|
||||
if (missingFields.length > 0) {
|
||||
toast.error(`필수 항목을 입력해주세요: ${missingFields.map(f => f.label).join(', ')}`);
|
||||
return;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const formData = getFormData();
|
||||
|
||||
// 수정 모드: 수정 후 상신
|
||||
if (isEditMode && documentId) {
|
||||
const result = await updateAndSubmitApproval(parseInt(documentId), formData);
|
||||
if (result.success) {
|
||||
invalidateDashboard('approval');
|
||||
toast.success('수정 및 상신 완료', {
|
||||
description: `문서번호: ${result.data?.documentNo}`,
|
||||
});
|
||||
// BUG #1 fix: router.push로 기안함 이동하여 데이터 확실히 로드
|
||||
router.push('/approval/draft');
|
||||
} else {
|
||||
toast.error(result.error || '문서 상신에 실패했습니다.');
|
||||
}
|
||||
} else {
|
||||
// 새 문서: 생성 후 상신
|
||||
const result = await createAndSubmitApproval(formData);
|
||||
if (result.success) {
|
||||
invalidateDashboard('approval');
|
||||
toast.success('상신 완료', {
|
||||
description: `문서번호: ${result.data?.documentNo}`,
|
||||
});
|
||||
// BUG #1 fix: router.push로 기안함 이동하여 데이터 확실히 로드
|
||||
router.push('/approval/draft');
|
||||
} else {
|
||||
toast.error(result.error || '문서 상신에 실패했습니다.');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('Submit error:', error);
|
||||
toast.error('문서 상신 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}, [approvalLine, getFormData, router, isEditMode, documentId]);
|
||||
|
||||
const handleSaveDraft = useCallback(async () => {
|
||||
startTransition(async () => {
|
||||
try {
|
||||
const formData = getFormData();
|
||||
if (!basicInfo.formId) {
|
||||
toast.error('양식을 선택해주세요.');
|
||||
return;
|
||||
}
|
||||
// BUG #13 fix: 빈 폼 사전 검증 — 최소 1개 필드 입력 필요
|
||||
if (!isEditMode) {
|
||||
const formData = getFormData();
|
||||
const dt = formData.basicInfo.documentType;
|
||||
const hasDedicatedContent =
|
||||
(dt === 'proposal' && formData.proposalData?.title) ||
|
||||
(dt === 'expenseReport' && (formData.expenseReportData?.items?.length ?? 0) > 0) ||
|
||||
(dt === 'expenseEstimate' && (formData.expenseEstimateData?.items?.length ?? 0) > 0);
|
||||
const hasDynamicContent = formData.dynamicFormData && Object.values(formData.dynamicFormData).some(v => v !== '' && v !== null && v !== undefined && v !== 0);
|
||||
const hasDedicatedFormContent = !isDedicatedForm(dt) ? false :
|
||||
[formData.officialDocumentData, formData.resignationData, formData.employmentCertData,
|
||||
formData.careerCertData, formData.appointmentCertData, formData.sealUsageData,
|
||||
formData.leaveNotice1stData, formData.leaveNotice2ndData, formData.powerOfAttorneyData,
|
||||
formData.boardMinutesData, formData.quotationData].some(d =>
|
||||
d && Object.values(d as unknown as Record<string, unknown>).some(v => v !== '' && v !== null && v !== undefined && v !== 0)
|
||||
);
|
||||
if (!hasDedicatedContent && !hasDynamicContent && !hasDedicatedFormContent) {
|
||||
toast.error('문서 내용을 최소 1개 이상 입력해주세요.');
|
||||
return;
|
||||
}
|
||||
}
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const formData = getFormData();
|
||||
|
||||
// 수정 모드: 기존 문서 업데이트
|
||||
if (isEditMode && documentId) {
|
||||
const result = await updateApproval(parseInt(documentId), formData);
|
||||
if (result.success) {
|
||||
invalidateDashboard('approval');
|
||||
toast.success('저장 완료', {
|
||||
description: `문서번호: ${result.data?.documentNo}`,
|
||||
});
|
||||
} else {
|
||||
toast.error(result.error || '저장에 실패했습니다.');
|
||||
// 수정 모드: 기존 문서 업데이트
|
||||
if (isEditMode && documentId) {
|
||||
const result = await updateApproval(parseInt(documentId), formData);
|
||||
if (result.success) {
|
||||
invalidateDashboard('approval');
|
||||
toast.success('저장 완료', {
|
||||
description: `문서번호: ${result.data?.documentNo}`,
|
||||
});
|
||||
} else {
|
||||
toast.error(result.error || '저장에 실패했습니다.');
|
||||
}
|
||||
} else {
|
||||
// 새 문서: 임시저장
|
||||
const result = await createApproval(formData);
|
||||
if (result.success) {
|
||||
invalidateDashboard('approval');
|
||||
toast.success('임시저장 완료', {
|
||||
description: `문서번호: ${result.data?.documentNo}`,
|
||||
});
|
||||
// 문서번호 업데이트
|
||||
if (result.data?.documentNo) {
|
||||
setBasicInfo(prev => ({ ...prev, documentNo: result.data!.documentNo }));
|
||||
}
|
||||
// BUG #11 fix: edit 모드로 전환하여 이후 저장이 updateApproval을 호출하도록
|
||||
if (result.data?.id) {
|
||||
setSavedDocId(String(result.data.id));
|
||||
}
|
||||
} else {
|
||||
// 새 문서: 임시저장
|
||||
const result = await createApproval(formData);
|
||||
if (result.success) {
|
||||
invalidateDashboard('approval');
|
||||
toast.success('임시저장 완료', {
|
||||
description: `문서번호: ${result.data?.documentNo}`,
|
||||
});
|
||||
// 문서번호 업데이트
|
||||
if (result.data?.documentNo) {
|
||||
setBasicInfo(prev => ({ ...prev, documentNo: result.data!.documentNo }));
|
||||
}
|
||||
} else {
|
||||
toast.error(result.error || '임시저장에 실패했습니다.');
|
||||
}
|
||||
toast.error(result.error || '임시저장에 실패했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('Save draft error:', error);
|
||||
toast.error('저장 중 오류가 발생했습니다.');
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('Save draft error:', error);
|
||||
toast.error('저장 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}, [getFormData, isEditMode, documentId]);
|
||||
|
||||
// 미리보기 핸들러
|
||||
@@ -461,7 +617,7 @@ export function DocumentCreate() {
|
||||
}, []);
|
||||
|
||||
// 미리보기용 데이터 변환
|
||||
const getPreviewData = useCallback((): ProposalDocumentData | ExpenseReportDocumentData | ExpenseEstimateDocumentData => {
|
||||
const getPreviewData = useCallback((): ProposalDocumentData | ExpenseReportDocumentData | ExpenseEstimateDocumentData | DynamicDocumentData => {
|
||||
const drafter = {
|
||||
id: 'drafter-1',
|
||||
name: basicInfo.drafter,
|
||||
@@ -511,12 +667,70 @@ export function DocumentCreate() {
|
||||
})),
|
||||
cardInfo: expenseReportData.cardId || '-',
|
||||
totalAmount: expenseReportData.totalAmount,
|
||||
attachments: expenseReportData.attachments.map(f => f.name),
|
||||
attachments: [
|
||||
...(expenseReportData.uploadedFiles || []).map(f => `/api/proxy/files/${f.id}/download`),
|
||||
...expenseReportData.attachments.map(f => URL.createObjectURL(f)),
|
||||
],
|
||||
approvers,
|
||||
drafter,
|
||||
};
|
||||
default: {
|
||||
// 이미 업로드된 파일 URL (Next.js 프록시 사용) + 새로 추가된 파일 미리보기 URL
|
||||
// 신규 전용 폼들: DynamicDocumentData 형태로 미리보기
|
||||
const dedicatedFormDataMap: Record<string, { formName: string; fields: Record<string, unknown> }> = {
|
||||
officialDocument: { formName: '공문서', fields: officialDocumentData as unknown as Record<string, unknown> },
|
||||
resignation: { formName: '사직서', fields: resignationData as unknown as Record<string, unknown> },
|
||||
employmentCert: { formName: '재직증명서', fields: employmentCertData as unknown as Record<string, unknown> },
|
||||
careerCert: { formName: '경력증명서', fields: careerCertData as unknown as Record<string, unknown> },
|
||||
appointmentCert: { formName: '위촉증명서', fields: appointmentCertData as unknown as Record<string, unknown> },
|
||||
sealUsage: { formName: '사용인감계', fields: sealUsageData as unknown as Record<string, unknown> },
|
||||
leaveNotice1st: { formName: '연차촉진 1차', fields: leaveNotice1stData as unknown as Record<string, unknown> },
|
||||
leaveNotice2nd: { formName: '연차촉진 2차', fields: leaveNotice2ndData as unknown as Record<string, unknown> },
|
||||
powerOfAttorney: { formName: '위임장', fields: powerOfAttorneyData as unknown as Record<string, unknown> },
|
||||
boardMinutes: { formName: '이사회의사록', fields: boardMinutesData as unknown as Record<string, unknown> },
|
||||
quotation: { formName: '견적서', fields: quotationData as unknown as Record<string, unknown> },
|
||||
};
|
||||
|
||||
const dedicatedPreview = dedicatedFormDataMap[basicInfo.documentType];
|
||||
if (dedicatedPreview) {
|
||||
return {
|
||||
documentNo: basicInfo.documentNo || '미발급',
|
||||
createdAt: basicInfo.draftDate,
|
||||
formName: dedicatedPreview.formName,
|
||||
formCategory: basicInfo.formCategory,
|
||||
fields: filterVisibleFields(dedicatedPreview.fields),
|
||||
fieldLabels: getFieldLabels(basicInfo.documentType),
|
||||
approvers,
|
||||
drafter,
|
||||
};
|
||||
}
|
||||
|
||||
// 동적 폼이면 DynamicDocumentData 반환
|
||||
if (basicInfo.formId && !isDedicatedForm(basicInfo.documentType)) {
|
||||
// 동적 폼 필드의 name → label 매핑 생성
|
||||
const fieldLabels: Record<string, string> = {};
|
||||
dynamicFormFields.forEach(f => {
|
||||
fieldLabels[f.name] = f.label;
|
||||
// array 타입의 columns도 매핑
|
||||
if (f.columns) {
|
||||
f.columns.forEach(col => {
|
||||
fieldLabels[col.name] = col.label;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
documentNo: basicInfo.documentNo || '미발급',
|
||||
createdAt: basicInfo.draftDate,
|
||||
formName: basicInfo.formName || '문서',
|
||||
formCategory: basicInfo.formCategory,
|
||||
fields: dynamicFormData,
|
||||
fieldLabels,
|
||||
approvers,
|
||||
drafter,
|
||||
};
|
||||
}
|
||||
|
||||
// 기본 품의서 폴백
|
||||
const uploadedFileUrls = (proposalData.uploadedFiles || []).map(f =>
|
||||
`/api/proxy/files/${f.id}/download`
|
||||
);
|
||||
@@ -537,7 +751,7 @@ export function DocumentCreate() {
|
||||
};
|
||||
}
|
||||
}
|
||||
}, [basicInfo, approvalLine, proposalData, expenseReportData, expenseEstimateData]);
|
||||
}, [basicInfo, approvalLine, proposalData, expenseReportData, expenseEstimateData, officialDocumentData, resignationData, employmentCertData, careerCertData, appointmentCertData, sealUsageData, leaveNotice1stData, leaveNotice2ndData, powerOfAttorneyData, boardMinutesData, quotationData, dynamicFormData, dynamicFormFields]);
|
||||
|
||||
// 문서 유형별 폼 렌더링
|
||||
const renderDocumentTypeForm = () => {
|
||||
@@ -548,11 +762,48 @@ export function DocumentCreate() {
|
||||
return <ExpenseReportForm data={expenseReportData} onChange={setExpenseReportData} />;
|
||||
case 'expenseEstimate':
|
||||
return <ExpenseEstimateForm data={expenseEstimateData} onChange={setExpenseEstimateData} isLoading={isLoadingEstimate} />;
|
||||
// 신규 12종 전용 폼
|
||||
case 'officialDocument':
|
||||
return <OfficialDocumentForm data={officialDocumentData} onChange={setOfficialDocumentData} />;
|
||||
case 'resignation':
|
||||
return <ResignationForm data={resignationData} onChange={setResignationData} />;
|
||||
case 'leaveNotice1st':
|
||||
return <LeaveNotice1stForm data={leaveNotice1stData} onChange={setLeaveNotice1stData} />;
|
||||
case 'leaveNotice2nd':
|
||||
return <LeaveNotice2ndForm data={leaveNotice2ndData} onChange={setLeaveNotice2ndData} />;
|
||||
case 'powerOfAttorney':
|
||||
return <PowerOfAttorneyForm data={powerOfAttorneyData} onChange={setPowerOfAttorneyData} />;
|
||||
case 'boardMinutes':
|
||||
return <BoardMinutesForm data={boardMinutesData} onChange={setBoardMinutesData} />;
|
||||
case 'employmentCert':
|
||||
return <EmploymentCertForm data={employmentCertData} onChange={setEmploymentCertData} />;
|
||||
case 'careerCert':
|
||||
return <CareerCertForm data={careerCertData} onChange={setCareerCertData} />;
|
||||
case 'appointmentCert':
|
||||
return <AppointmentCertForm data={appointmentCertData} onChange={setAppointmentCertData} />;
|
||||
case 'sealUsage':
|
||||
return <SealUsageForm data={sealUsageData} onChange={setSealUsageData} />;
|
||||
case 'quotation':
|
||||
return <QuotationForm data={quotationData} onChange={setQuotationData} />;
|
||||
default:
|
||||
// 동적 폼: API template 기반 렌더링
|
||||
if (basicInfo.formId) {
|
||||
return (
|
||||
<DynamicFormRenderer
|
||||
formId={basicInfo.formId}
|
||||
data={dynamicFormData}
|
||||
onChange={setDynamicFormData}
|
||||
onFieldsLoaded={setDynamicFormFields}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// 양식이 선택되었는지 여부 (결재선/참조/폼 섹션 표시 조건)
|
||||
const isFormSelected = !!basicInfo.formId;
|
||||
|
||||
// 현재 모드에 맞는 config 선택
|
||||
const currentConfig = isEditMode
|
||||
? documentEditConfig
|
||||
@@ -562,11 +813,11 @@ export function DocumentCreate() {
|
||||
|
||||
// 헤더 액션 버튼 (config 배열 → 모바일 아이콘 패턴 자동 적용)
|
||||
const headerActionItems = useMemo<ActionItem[]>(() => [
|
||||
{ icon: Eye, label: '미리보기', onClick: handlePreview, variant: 'outline' },
|
||||
{ icon: Trash2, label: '삭제', onClick: handleDelete, variant: 'outline', hidden: !canDelete, disabled: isPending },
|
||||
{ icon: Send, label: '상신', onClick: handleSubmit, variant: 'outline', disabled: isPending || !canCreate, loading: isPending },
|
||||
{ icon: Save, label: isEditMode ? '저장' : '임시저장', onClick: handleSaveDraft, variant: 'outline', disabled: isPending, loading: isPending },
|
||||
], [handlePreview, handleDelete, handleSubmit, handleSaveDraft, isPending, isEditMode, canCreate, canDelete]);
|
||||
{ icon: Eye, label: '미리보기', onClick: handlePreview, variant: 'outline', disabled: !isFormSelected },
|
||||
{ icon: Trash2, label: '삭제', onClick: handleDelete, variant: 'outline', hidden: !canDelete, disabled: isSubmitting },
|
||||
{ icon: Send, label: '상신', onClick: handleSubmit, variant: 'outline', disabled: isSubmitting || !canCreate || !isFormSelected, loading: isSubmitting },
|
||||
{ icon: Save, label: isEditMode ? '저장' : '임시저장', onClick: handleSaveDraft, variant: 'outline', disabled: isSubmitting || !isFormSelected, loading: isSubmitting },
|
||||
], [handlePreview, handleDelete, handleSubmit, handleSaveDraft, isSubmitting, isEditMode, canCreate, canDelete, isFormSelected]);
|
||||
|
||||
// 폼 컨텐츠 렌더링
|
||||
const renderFormContent = useCallback(() => {
|
||||
@@ -575,17 +826,22 @@ export function DocumentCreate() {
|
||||
{/* 기본 정보 */}
|
||||
<BasicInfoSection data={basicInfo} onChange={setBasicInfo} />
|
||||
|
||||
{/* 결재선 */}
|
||||
<ApprovalLineSection data={approvalLine} onChange={setApprovalLine} />
|
||||
{/* 양식 선택 후에만 결재선/참조/폼 표시 */}
|
||||
{isFormSelected && (
|
||||
<>
|
||||
{/* 결재선 */}
|
||||
<ApprovalLineSection data={approvalLine} onChange={setApprovalLine} />
|
||||
|
||||
{/* 참조 */}
|
||||
<ReferenceSection data={references} onChange={setReferences} />
|
||||
{/* 참조 */}
|
||||
<ReferenceSection data={references} onChange={setReferences} />
|
||||
|
||||
{/* 문서 유형별 폼 */}
|
||||
{renderDocumentTypeForm()}
|
||||
{/* 문서 유형별 폼 */}
|
||||
{renderDocumentTypeForm()}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}, [basicInfo, approvalLine, references, renderDocumentTypeForm]);
|
||||
}, [basicInfo, approvalLine, references, renderDocumentTypeForm, isFormSelected]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -602,7 +858,7 @@ export function DocumentCreate() {
|
||||
<DocumentDetailModal
|
||||
open={isPreviewOpen}
|
||||
onOpenChange={setIsPreviewOpen}
|
||||
documentType={basicInfo.documentType as ModalDocumentType}
|
||||
documentType={['proposal', 'expenseReport', 'expenseEstimate'].includes(basicInfo.documentType) ? basicInfo.documentType as ModalDocumentType : 'dynamic'}
|
||||
data={getPreviewData()}
|
||||
mode="draft"
|
||||
documentStatus="draft"
|
||||
|
||||
@@ -9,21 +9,46 @@ export interface UploadedFile {
|
||||
mime_type?: string;
|
||||
}
|
||||
|
||||
// 문서 유형
|
||||
export type DocumentType = 'proposal' | 'expenseReport' | 'expenseEstimate';
|
||||
// 문서 유형 (기존 3종 + API 기반 확장)
|
||||
export type DocumentType = 'proposal' | 'expenseReport' | 'expenseEstimate' | string;
|
||||
|
||||
// 전용 폼이 있는 코드 (API code + frontend camelCase 모두 포함)
|
||||
export const DEDICATED_FORM_CODES = [
|
||||
// 기존 3종
|
||||
'proposal', 'expenseReport', 'expense_report', 'expenseEstimate', 'expense_estimate',
|
||||
// 일반
|
||||
'official_letter', 'officialDocument',
|
||||
// 인사/근태
|
||||
'resignation',
|
||||
'leave_promotion_1st', 'leaveNotice1st',
|
||||
'leave_promotion_2nd', 'leaveNotice2nd',
|
||||
'delegation', 'powerOfAttorney',
|
||||
'board_minutes', 'boardMinutes',
|
||||
// 증명서
|
||||
'employment_cert', 'employmentCert',
|
||||
'career_cert', 'careerCert',
|
||||
'appointment_cert', 'appointmentCert',
|
||||
'seal_usage', 'sealUsage',
|
||||
// 재무
|
||||
'quotation',
|
||||
] as const;
|
||||
|
||||
export const DOCUMENT_TYPE_OPTIONS: { value: DocumentType; label: string }[] = [
|
||||
{ value: 'proposal', label: '품의서' },
|
||||
{ value: 'expenseReport', label: '지출결의서' },
|
||||
{ value: 'expenseEstimate', label: '지출 예상 내역서' },
|
||||
{ value: 'expenseEstimate', label: '비용견적서' },
|
||||
];
|
||||
|
||||
// 결재 단계 유형
|
||||
export type StepType = 'approval' | 'agreement';
|
||||
|
||||
// 결재자/참조자 정보
|
||||
export interface ApprovalPerson {
|
||||
id: string;
|
||||
department: string;
|
||||
position: string;
|
||||
name: string;
|
||||
stepType?: StepType; // 결재(approval) 또는 합의(agreement)
|
||||
}
|
||||
|
||||
// 기본 정보
|
||||
@@ -34,6 +59,11 @@ export interface BasicInfo {
|
||||
draftDate: string;
|
||||
documentNo: string;
|
||||
documentType: DocumentType;
|
||||
formId?: number; // API 양식 ID
|
||||
formCode?: string; // API 양식 코드
|
||||
formName?: string; // API 양식 이름
|
||||
formCategory?: string; // API 양식 카테고리
|
||||
isUrgent?: boolean; // 긴급 여부
|
||||
}
|
||||
|
||||
// 품의서 데이터
|
||||
@@ -68,7 +98,7 @@ export interface ExpenseReportData {
|
||||
uploadedFiles?: UploadedFile[]; // 이미 업로드된 파일
|
||||
}
|
||||
|
||||
// 지출 예상 내역서 항목
|
||||
// 비용견적서 항목
|
||||
export interface ExpenseEstimateItem {
|
||||
id: string;
|
||||
checked: boolean;
|
||||
@@ -79,14 +109,198 @@ export interface ExpenseEstimateItem {
|
||||
memo: string;
|
||||
}
|
||||
|
||||
// 지출 예상 내역서 데이터
|
||||
// 비용견적서 데이터
|
||||
export interface ExpenseEstimateData {
|
||||
title: string;
|
||||
body: string;
|
||||
items: ExpenseEstimateItem[];
|
||||
totalExpense: number;
|
||||
accountBalance: number;
|
||||
finalDifference: number;
|
||||
}
|
||||
|
||||
// ===== 신규 12종 전용 폼 데이터 =====
|
||||
|
||||
// 공문서
|
||||
export interface OfficialDocumentData {
|
||||
documentNumber: string;
|
||||
documentDate: string;
|
||||
recipient: string;
|
||||
reference: string;
|
||||
title: string;
|
||||
body: string;
|
||||
attachment: string;
|
||||
companyName: string;
|
||||
representativeName: string;
|
||||
address: string;
|
||||
phone: string;
|
||||
fax: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
// 사직서
|
||||
export interface ResignationData {
|
||||
employeeId: string;
|
||||
department: string;
|
||||
position: string;
|
||||
employeeName: string;
|
||||
residentNumber: string;
|
||||
joinDate: string;
|
||||
resignDate: string;
|
||||
employeeAddress: string;
|
||||
reasonType: string;
|
||||
reasonDetail: string;
|
||||
submitDate: string;
|
||||
}
|
||||
|
||||
// 재직증명서
|
||||
export interface EmploymentCertData {
|
||||
employeeId: string;
|
||||
employeeName: string;
|
||||
residentNumber: string;
|
||||
employeeAddress: string;
|
||||
companyName: string;
|
||||
businessNumber: string;
|
||||
department: string;
|
||||
position: string;
|
||||
employmentPeriodStart: string;
|
||||
employmentPeriodEnd: string;
|
||||
purpose: string;
|
||||
issueDate: string;
|
||||
}
|
||||
|
||||
// 경력증명서
|
||||
export interface CareerCertData {
|
||||
employeeId: string;
|
||||
employeeName: string;
|
||||
residentNumber: string;
|
||||
birthDate: string;
|
||||
employeeAddress: string;
|
||||
companyName: string;
|
||||
businessNumber: string;
|
||||
representativeName: string;
|
||||
companyPhone: string;
|
||||
companyAddress: string;
|
||||
department: string;
|
||||
positionTitle: string;
|
||||
workPeriodStart: string;
|
||||
workPeriodEnd: string;
|
||||
duties: string;
|
||||
purpose: string;
|
||||
issueDate: string;
|
||||
}
|
||||
|
||||
// 위촉증명서
|
||||
export interface AppointmentCertData {
|
||||
employeeId: string;
|
||||
employeeName: string;
|
||||
residentNumber: string;
|
||||
department: string;
|
||||
phone: string;
|
||||
appointmentPeriodStart: string;
|
||||
appointmentPeriodEnd: string;
|
||||
contractQualification: string;
|
||||
purpose: string;
|
||||
issueDate: string;
|
||||
}
|
||||
|
||||
// 사용인감계
|
||||
export interface SealUsageData {
|
||||
sealImprint: string;
|
||||
purpose: string;
|
||||
submitTo: string;
|
||||
attachedDocuments: string;
|
||||
usageDate: string;
|
||||
companyName: string;
|
||||
businessNumber: string;
|
||||
address: string;
|
||||
representativeName: string;
|
||||
}
|
||||
|
||||
// 연차촉진 1차
|
||||
export interface LeaveNotice1stData {
|
||||
employeeId: string;
|
||||
department: string;
|
||||
position: string;
|
||||
totalDays: number;
|
||||
usedDays: number;
|
||||
remainingDays: number;
|
||||
deadline: string;
|
||||
legalNotice: string;
|
||||
}
|
||||
|
||||
// 연차촉진 2차
|
||||
export interface LeaveNotice2ndData {
|
||||
employeeId: string;
|
||||
department: string;
|
||||
position: string;
|
||||
remainingDays: number;
|
||||
designatedDates: string[];
|
||||
legalNotice: string;
|
||||
}
|
||||
|
||||
// 위임장
|
||||
export interface PowerOfAttorneyData {
|
||||
principalCompanyName: string;
|
||||
principalBusinessNumber: string;
|
||||
principalAddress: string;
|
||||
principalRepresentative: string;
|
||||
agentName: string;
|
||||
agentBirthDate: string;
|
||||
agentAddress: string;
|
||||
agentPhone: string;
|
||||
agentDepartment: string;
|
||||
delegationDetails: string;
|
||||
delegationPeriodStart: string;
|
||||
delegationPeriodEnd: string;
|
||||
attachedDocuments: string;
|
||||
}
|
||||
|
||||
// 이사회의사록
|
||||
export interface BoardMinutesData {
|
||||
meetingDate: string;
|
||||
meetingPlace: string;
|
||||
totalDirectors: number;
|
||||
attendingDirectors: number;
|
||||
totalAuditors: number;
|
||||
attendingAuditors: number;
|
||||
agendaTitle: string;
|
||||
agendaResult: string;
|
||||
chairperson: string;
|
||||
proceedings: string;
|
||||
adjournmentTime: string;
|
||||
signatories: string;
|
||||
}
|
||||
|
||||
// 견적서 항목
|
||||
export interface QuotationItem {
|
||||
id: string;
|
||||
name: string;
|
||||
specification: string;
|
||||
quantity: number;
|
||||
unitPrice: number;
|
||||
supplyAmount: number;
|
||||
tax: number;
|
||||
note: string;
|
||||
}
|
||||
|
||||
// 견적서
|
||||
export interface QuotationData {
|
||||
recipientName: string;
|
||||
quotationDate: string;
|
||||
businessNumber: string;
|
||||
companyName: string;
|
||||
representativeName: string;
|
||||
companyAddress: string;
|
||||
businessType: string;
|
||||
businessCategory: string;
|
||||
companyPhone: string;
|
||||
accountNumber: string;
|
||||
autoTax: boolean;
|
||||
items: QuotationItem[];
|
||||
specialNotes: string;
|
||||
}
|
||||
|
||||
// 전체 문서 데이터
|
||||
export interface DocumentFormData {
|
||||
basicInfo: BasicInfo;
|
||||
@@ -95,6 +309,18 @@ export interface DocumentFormData {
|
||||
proposalData?: ProposalData;
|
||||
expenseReportData?: ExpenseReportData;
|
||||
expenseEstimateData?: ExpenseEstimateData;
|
||||
officialDocumentData?: OfficialDocumentData;
|
||||
resignationData?: ResignationData;
|
||||
employmentCertData?: EmploymentCertData;
|
||||
careerCertData?: CareerCertData;
|
||||
appointmentCertData?: AppointmentCertData;
|
||||
sealUsageData?: SealUsageData;
|
||||
leaveNotice1stData?: LeaveNotice1stData;
|
||||
leaveNotice2ndData?: LeaveNotice2ndData;
|
||||
powerOfAttorneyData?: PowerOfAttorneyData;
|
||||
boardMinutesData?: BoardMinutesData;
|
||||
quotationData?: QuotationData;
|
||||
dynamicFormData?: Record<string, unknown>; // 동적 폼 데이터 (API template 기반)
|
||||
}
|
||||
|
||||
// 카드 옵션
|
||||
|
||||
@@ -5,12 +5,14 @@ import { ProposalDocument } from './ProposalDocument';
|
||||
import { ExpenseReportDocument } from './ExpenseReportDocument';
|
||||
import { ExpenseEstimateDocument } from './ExpenseEstimateDocument';
|
||||
import { LinkedDocumentContent } from './LinkedDocumentContent';
|
||||
import { DynamicDocument } from './DynamicDocument';
|
||||
import type {
|
||||
DocumentDetailModalProps,
|
||||
ProposalDocumentData,
|
||||
ExpenseReportDocumentData,
|
||||
ExpenseEstimateDocumentData,
|
||||
LinkedDocumentData,
|
||||
DynamicDocumentData,
|
||||
} from './types';
|
||||
|
||||
/**
|
||||
@@ -41,9 +43,11 @@ export function DocumentDetailModalV2({
|
||||
case 'expenseReport':
|
||||
return '지출결의서';
|
||||
case 'expenseEstimate':
|
||||
return '지출 예상 내역서';
|
||||
return '비용견적서';
|
||||
case 'document':
|
||||
return (data as LinkedDocumentData).templateName || '문서 결재';
|
||||
case 'dynamic':
|
||||
return (data as DynamicDocumentData).formName || '문서';
|
||||
default:
|
||||
return '문서';
|
||||
}
|
||||
@@ -74,6 +78,8 @@ export function DocumentDetailModalV2({
|
||||
return <ExpenseEstimateDocument data={data as ExpenseEstimateDocumentData} />;
|
||||
case 'document':
|
||||
return <LinkedDocumentContent data={data as LinkedDocumentData} />;
|
||||
case 'dynamic':
|
||||
return <DynamicDocument data={data as DynamicDocumentData} />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
128
src/components/approval/DocumentDetail/DynamicDocument.tsx
Normal file
128
src/components/approval/DocumentDetail/DynamicDocument.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 동적 양식 문서 컴포넌트
|
||||
*
|
||||
* API template 기반 동적 폼 데이터를 문서 형태로 렌더링
|
||||
*/
|
||||
|
||||
import { ApprovalLineBox } from './ApprovalLineBox';
|
||||
import type { DynamicDocumentData } from './types';
|
||||
import { DocumentHeader } from '@/components/document-system';
|
||||
import { formatNumber } from '@/lib/utils/amount';
|
||||
|
||||
interface DynamicDocumentProps {
|
||||
data: DynamicDocumentData;
|
||||
}
|
||||
|
||||
export function DynamicDocument({ data }: DynamicDocumentProps) {
|
||||
const fieldEntries = Object.entries(data.fields).filter(
|
||||
([, value]) => value !== undefined && value !== null && value !== ''
|
||||
);
|
||||
const labels = data.fieldLabels || {};
|
||||
|
||||
return (
|
||||
<div className="bg-white p-8 min-h-full">
|
||||
{/* 문서 헤더 */}
|
||||
<DocumentHeader
|
||||
title={data.formName}
|
||||
subtitle={`문서번호: ${data.documentNo} | 작성일자: ${data.createdAt}`}
|
||||
layout="centered"
|
||||
customApproval={<ApprovalLineBox drafter={data.drafter} approvers={data.approvers} />}
|
||||
/>
|
||||
|
||||
{/* 문서 내용 */}
|
||||
<div className="border border-gray-300">
|
||||
{fieldEntries.length === 0 ? (
|
||||
<div className="p-6 text-center text-sm text-gray-400">
|
||||
입력된 내용이 없습니다.
|
||||
</div>
|
||||
) : (
|
||||
fieldEntries.map(([key, value]) => (
|
||||
<div key={key} className="flex border-b border-gray-300 last:border-b-0">
|
||||
<div className="w-36 bg-gray-100 p-3 font-medium text-sm border-r border-gray-300 shrink-0">
|
||||
{labels[key] || key}
|
||||
</div>
|
||||
<div className="flex-1 p-3 text-sm">
|
||||
{renderFieldValue(value, labels)}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function renderFieldValue(value: unknown, labels?: Record<string, string>): React.ReactNode {
|
||||
if (value === null || value === undefined) return '-';
|
||||
|
||||
// 숫자
|
||||
if (typeof value === 'number') {
|
||||
return formatNumber(value);
|
||||
}
|
||||
|
||||
// 문자열
|
||||
if (typeof value === 'string') {
|
||||
return value || '-';
|
||||
}
|
||||
|
||||
// 불리언
|
||||
if (typeof value === 'boolean') {
|
||||
return value ? '예' : '아니오';
|
||||
}
|
||||
|
||||
// 배열 (테이블 데이터)
|
||||
if (Array.isArray(value)) {
|
||||
if (value.length === 0) return '-';
|
||||
|
||||
// 객체 배열이면 테이블로 렌더링
|
||||
if (typeof value[0] === 'object' && value[0] !== null) {
|
||||
const columns = Object.keys(value[0] as Record<string, unknown>);
|
||||
return (
|
||||
<table className="w-full border text-xs">
|
||||
<thead>
|
||||
<tr className="bg-gray-50">
|
||||
<th className="border px-2 py-1 w-10">No</th>
|
||||
{columns.map((col) => (
|
||||
<th key={col} className="border px-2 py-1">{labels?.[col] || col}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{value.map((row, idx) => (
|
||||
<tr key={idx}>
|
||||
<td className="border px-2 py-1 text-center">{idx + 1}</td>
|
||||
{columns.map((col) => (
|
||||
<td key={col} className="border px-2 py-1">
|
||||
{formatCellValue((row as Record<string, unknown>)[col])}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
|
||||
// 단순 배열
|
||||
return value.join(', ');
|
||||
}
|
||||
|
||||
// 날짜 범위 객체
|
||||
if (typeof value === 'object' && value !== null) {
|
||||
const obj = value as Record<string, unknown>;
|
||||
if ('start' in obj && 'end' in obj) {
|
||||
return `${obj.start || '-'} ~ ${obj.end || '-'}`;
|
||||
}
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function formatCellValue(value: unknown): string {
|
||||
if (value === null || value === undefined) return '-';
|
||||
if (typeof value === 'number') return formatNumber(value);
|
||||
return String(value);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 지출 예상 내역서 문서 컴포넌트
|
||||
* 비용견적서 문서 컴포넌트
|
||||
*
|
||||
* 공통 컴포넌트 사용:
|
||||
* - DocumentHeader: centered 레이아웃 + customApproval (ApprovalLineBox)
|
||||
@@ -42,7 +42,7 @@ export function ExpenseEstimateDocument({ data }: ExpenseEstimateDocumentProps)
|
||||
<div className="bg-white p-8 min-h-full">
|
||||
{/* 문서 헤더 (공통 컴포넌트) */}
|
||||
<DocumentHeader
|
||||
title="지출 예상 내역서"
|
||||
title="비용견적서"
|
||||
subtitle={`문서번호: ${data.documentNo} | 작성일자: ${data.createdAt}`}
|
||||
layout="centered"
|
||||
customApproval={<ApprovalLineBox drafter={data.drafter} approvers={data.approvers} />}
|
||||
@@ -50,9 +50,9 @@ export function ExpenseEstimateDocument({ data }: ExpenseEstimateDocumentProps)
|
||||
|
||||
{/* 문서 내용 */}
|
||||
<div className="border border-gray-300">
|
||||
{/* 지출 예상 내역서 헤더 */}
|
||||
{/* 비용견적서 헤더 */}
|
||||
<div className="bg-gray-800 text-white p-2 text-sm font-medium text-center">
|
||||
지출 예상 내역서 목록
|
||||
비용견적서 목록
|
||||
</div>
|
||||
|
||||
{/* 테이블 */}
|
||||
|
||||
@@ -119,7 +119,7 @@ export function ExpenseReportDocument({ data }: ExpenseReportDocumentProps) {
|
||||
key={index}
|
||||
src={url}
|
||||
alt={`첨부 이미지 ${index + 1}`}
|
||||
className="w-full h-32 object-cover rounded border"
|
||||
className="w-full max-h-48 object-contain rounded border"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -97,7 +97,7 @@ export function ProposalDocument({ data }: ProposalDocumentProps) {
|
||||
key={index}
|
||||
src={url}
|
||||
alt={`첨부 이미지 ${index + 1}`}
|
||||
className="w-full h-32 object-cover rounded border"
|
||||
className="w-full max-h-48 object-contain rounded border"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
292
src/components/approval/DocumentDetail/field-labels.ts
Normal file
292
src/components/approval/DocumentDetail/field-labels.ts
Normal file
@@ -0,0 +1,292 @@
|
||||
/**
|
||||
* 전용 양식별 한글 필드 라벨 매핑
|
||||
*
|
||||
* DynamicDocument에서 영문 필드키 대신 한글 라벨을 표시하기 위한 공통 모듈.
|
||||
* DocumentCreate의 getPreviewData()와 각 Box의 convertToModalData()에서 공유.
|
||||
*/
|
||||
|
||||
// 미리보기에서 숨길 내부 필드 (ID, 시스템 값)
|
||||
const HIDDEN_FIELDS = new Set([
|
||||
'employeeId',
|
||||
'vendorId',
|
||||
'autoTax',
|
||||
]);
|
||||
|
||||
/** 공문서 */
|
||||
const officialDocumentLabels: Record<string, string> = {
|
||||
documentNumber: '문서번호',
|
||||
documentDate: '문서일자',
|
||||
recipient: '수신',
|
||||
reference: '참조',
|
||||
title: '제목',
|
||||
body: '본문',
|
||||
attachment: '붙임',
|
||||
companyName: '회사명',
|
||||
representativeName: '대표자명',
|
||||
address: '주소',
|
||||
phone: '전화',
|
||||
fax: '팩스',
|
||||
email: '이메일',
|
||||
};
|
||||
|
||||
/** 사직서 */
|
||||
const resignationLabels: Record<string, string> = {
|
||||
department: '부서',
|
||||
position: '직위',
|
||||
employeeName: '성명',
|
||||
residentNumber: '주민등록번호',
|
||||
joinDate: '입사일',
|
||||
resignDate: '퇴사일',
|
||||
employeeAddress: '주소',
|
||||
reasonType: '사직사유',
|
||||
reasonDetail: '사유상세',
|
||||
submitDate: '제출일',
|
||||
};
|
||||
|
||||
/** 재직증명서 */
|
||||
const employmentCertLabels: Record<string, string> = {
|
||||
employeeName: '성명',
|
||||
residentNumber: '주민등록번호',
|
||||
employeeAddress: '주소',
|
||||
companyName: '회사명',
|
||||
businessNumber: '사업자등록번호',
|
||||
department: '부서',
|
||||
position: '직위',
|
||||
employmentPeriodStart: '재직기간 시작',
|
||||
employmentPeriodEnd: '재직기간 종료',
|
||||
purpose: '용도',
|
||||
issueDate: '발급일',
|
||||
};
|
||||
|
||||
/** 경력증명서 */
|
||||
const careerCertLabels: Record<string, string> = {
|
||||
employeeName: '성명',
|
||||
residentNumber: '주민등록번호',
|
||||
birthDate: '생년월일',
|
||||
employeeAddress: '주소',
|
||||
companyName: '회사명',
|
||||
businessNumber: '사업자등록번호',
|
||||
representativeName: '대표자명',
|
||||
companyPhone: '회사전화',
|
||||
companyAddress: '회사주소',
|
||||
department: '부서',
|
||||
positionTitle: '직위/직책',
|
||||
workPeriodStart: '근무기간 시작',
|
||||
workPeriodEnd: '근무기간 종료',
|
||||
duties: '담당업무',
|
||||
purpose: '용도',
|
||||
issueDate: '발급일',
|
||||
};
|
||||
|
||||
/** 위촉증명서 */
|
||||
const appointmentCertLabels: Record<string, string> = {
|
||||
employeeName: '성명',
|
||||
residentNumber: '주민등록번호',
|
||||
department: '부서',
|
||||
phone: '연락처',
|
||||
appointmentPeriodStart: '위촉기간 시작',
|
||||
appointmentPeriodEnd: '위촉기간 종료',
|
||||
contractQualification: '계약자격',
|
||||
purpose: '용도',
|
||||
issueDate: '발급일',
|
||||
};
|
||||
|
||||
/** 사용인감계 */
|
||||
const sealUsageLabels: Record<string, string> = {
|
||||
sealImprint: '인감날인',
|
||||
purpose: '사용목적',
|
||||
submitTo: '제출처',
|
||||
attachedDocuments: '첨부서류',
|
||||
usageDate: '사용일',
|
||||
companyName: '회사명',
|
||||
businessNumber: '사업자등록번호',
|
||||
address: '주소',
|
||||
representativeName: '대표자명',
|
||||
};
|
||||
|
||||
/** 연차촉진 1차 */
|
||||
const leaveNotice1stLabels: Record<string, string> = {
|
||||
department: '부서',
|
||||
position: '직위',
|
||||
totalDays: '총 연차일수',
|
||||
usedDays: '사용일수',
|
||||
remainingDays: '잔여일수',
|
||||
deadline: '사용기한',
|
||||
legalNotice: '법적고지',
|
||||
};
|
||||
|
||||
/** 연차촉진 2차 */
|
||||
const leaveNotice2ndLabels: Record<string, string> = {
|
||||
department: '부서',
|
||||
position: '직위',
|
||||
remainingDays: '잔여일수',
|
||||
designatedDates: '지정일자',
|
||||
legalNotice: '법적고지',
|
||||
};
|
||||
|
||||
/** 위임장 */
|
||||
const powerOfAttorneyLabels: Record<string, string> = {
|
||||
principalCompanyName: '위임자 회사명',
|
||||
principalBusinessNumber: '위임자 사업자등록번호',
|
||||
principalAddress: '위임자 주소',
|
||||
principalRepresentative: '위임자 대표자',
|
||||
agentName: '대리인 성명',
|
||||
agentBirthDate: '대리인 생년월일',
|
||||
agentAddress: '대리인 주소',
|
||||
agentPhone: '대리인 연락처',
|
||||
agentDepartment: '대리인 소속부서',
|
||||
delegationDetails: '위임사항',
|
||||
delegationPeriodStart: '위임기간 시작',
|
||||
delegationPeriodEnd: '위임기간 종료',
|
||||
attachedDocuments: '첨부서류',
|
||||
};
|
||||
|
||||
/** 이사회의사록 */
|
||||
const boardMinutesLabels: Record<string, string> = {
|
||||
meetingDate: '회의일시',
|
||||
meetingPlace: '회의장소',
|
||||
totalDirectors: '이사 총원',
|
||||
attendingDirectors: '출석 이사',
|
||||
totalAuditors: '감사 총원',
|
||||
attendingAuditors: '출석 감사',
|
||||
agendaTitle: '의안',
|
||||
agendaResult: '의결결과',
|
||||
chairperson: '의장',
|
||||
proceedings: '회의내용',
|
||||
adjournmentTime: '폐회시간',
|
||||
signatories: '서명인',
|
||||
};
|
||||
|
||||
/** 견적서 */
|
||||
const quotationLabels: Record<string, string> = {
|
||||
recipientName: '수신',
|
||||
quotationDate: '견적일자',
|
||||
businessNumber: '사업자등록번호',
|
||||
companyName: '회사명',
|
||||
representativeName: '대표자명',
|
||||
companyAddress: '회사주소',
|
||||
businessType: '업태',
|
||||
businessCategory: '종목',
|
||||
companyPhone: '전화',
|
||||
accountNumber: '계좌번호',
|
||||
items: '견적항목',
|
||||
specialNotes: '특기사항',
|
||||
};
|
||||
|
||||
// 동적 폼: 근태신청
|
||||
const attendanceRequestLabels: Record<string, string> = {
|
||||
user_name: '신청자',
|
||||
request_type: '신청유형',
|
||||
period: '기간',
|
||||
days: '일수',
|
||||
reason: '사유',
|
||||
};
|
||||
|
||||
// 동적 폼: 사유서
|
||||
const reasonReportLabels: Record<string, string> = {
|
||||
user_name: '작성자',
|
||||
report_type: '사유유형',
|
||||
target_date: '대상일',
|
||||
reason: '사유',
|
||||
};
|
||||
|
||||
// 동적 폼: 품의서 (proposal은 전용 폼이지만 content 키가 영문일 수 있음)
|
||||
const proposalLabels: Record<string, string> = {
|
||||
title: '제목',
|
||||
vendor: '거래처',
|
||||
description: '내용',
|
||||
reason: '사유',
|
||||
estimatedCost: '예상비용',
|
||||
};
|
||||
|
||||
/** 양식코드 → 한글 라벨 맵 */
|
||||
const FORM_FIELD_LABELS: Record<string, Record<string, string>> = {
|
||||
attendance_request: attendanceRequestLabels,
|
||||
reason_report: reasonReportLabels,
|
||||
proposal: proposalLabels,
|
||||
officialDocument: officialDocumentLabels,
|
||||
official_letter: officialDocumentLabels,
|
||||
resignation: resignationLabels,
|
||||
employmentCert: employmentCertLabels,
|
||||
employment_cert: employmentCertLabels,
|
||||
careerCert: careerCertLabels,
|
||||
career_cert: careerCertLabels,
|
||||
appointmentCert: appointmentCertLabels,
|
||||
appointment_cert: appointmentCertLabels,
|
||||
sealUsage: sealUsageLabels,
|
||||
seal_usage: sealUsageLabels,
|
||||
leaveNotice1st: leaveNotice1stLabels,
|
||||
leave_promotion_1st: leaveNotice1stLabels,
|
||||
leaveNotice2nd: leaveNotice2ndLabels,
|
||||
leave_promotion_2nd: leaveNotice2ndLabels,
|
||||
powerOfAttorney: powerOfAttorneyLabels,
|
||||
delegation: powerOfAttorneyLabels,
|
||||
boardMinutes: boardMinutesLabels,
|
||||
board_minutes: boardMinutesLabels,
|
||||
quotation: quotationLabels,
|
||||
};
|
||||
|
||||
/** 양식코드 → 한글 양식명 */
|
||||
const FORM_NAME_MAP: Record<string, string> = {
|
||||
attendance_request: '근태신청',
|
||||
reason_report: '사유서',
|
||||
proposal: '품의서',
|
||||
officialDocument: '공문서',
|
||||
official_letter: '공문서',
|
||||
resignation: '사직서',
|
||||
employmentCert: '재직증명서',
|
||||
employment_cert: '재직증명서',
|
||||
careerCert: '경력증명서',
|
||||
career_cert: '경력증명서',
|
||||
appointmentCert: '위촉증명서',
|
||||
appointment_cert: '위촉증명서',
|
||||
sealUsage: '사용인감계',
|
||||
seal_usage: '사용인감계',
|
||||
leaveNotice1st: '연차촉진 1차',
|
||||
leave_promotion_1st: '연차촉진 1차',
|
||||
leaveNotice2nd: '연차촉진 2차',
|
||||
leave_promotion_2nd: '연차촉진 2차',
|
||||
powerOfAttorney: '위임장',
|
||||
delegation: '위임장',
|
||||
boardMinutes: '이사회의사록',
|
||||
board_minutes: '이사회의사록',
|
||||
quotation: '견적서',
|
||||
expenseReport: '지출결의서',
|
||||
expense_report: '지출결의서',
|
||||
expenseEstimate: '비용견적서',
|
||||
expense_estimate: '비용견적서',
|
||||
};
|
||||
|
||||
/**
|
||||
* 양식코드에 해당하는 필드 라벨 맵 반환
|
||||
*/
|
||||
export function getFieldLabels(formCode: string): Record<string, string> | undefined {
|
||||
return FORM_FIELD_LABELS[formCode];
|
||||
}
|
||||
|
||||
/**
|
||||
* 양식코드에 해당하는 한글 양식명 반환
|
||||
*/
|
||||
export function getFormName(formCode: string): string {
|
||||
return FORM_NAME_MAP[formCode] || formCode;
|
||||
}
|
||||
|
||||
/**
|
||||
* 미리보기에서 숨길 내부 필드인지 확인
|
||||
*/
|
||||
export function isHiddenField(key: string): boolean {
|
||||
return HIDDEN_FIELDS.has(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* fields에서 숨길 필드를 제거한 새 객체 반환
|
||||
*/
|
||||
export function filterVisibleFields(fields: Record<string, unknown>): Record<string, unknown> {
|
||||
const result: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(fields)) {
|
||||
if (!HIDDEN_FIELDS.has(key)) {
|
||||
result[key] = value;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -47,7 +47,7 @@ export function DocumentDetailModal({
|
||||
case 'expenseReport':
|
||||
return '지출결의서';
|
||||
case 'expenseEstimate':
|
||||
return '지출 예상 내역서';
|
||||
return '비용견적서';
|
||||
default:
|
||||
return '문서';
|
||||
}
|
||||
@@ -179,4 +179,5 @@ export { ExpenseReportDocument } from './ExpenseReportDocument';
|
||||
export { ExpenseEstimateDocument } from './ExpenseEstimateDocument';
|
||||
|
||||
// V2 - DocumentViewer 기반
|
||||
export { DocumentDetailModalV2 } from './DocumentDetailModalV2';
|
||||
export { DocumentDetailModalV2 } from './DocumentDetailModalV2';
|
||||
export { DynamicDocument } from './DynamicDocument';
|
||||
@@ -1,6 +1,6 @@
|
||||
// ===== 문서 상세 모달 타입 정의 =====
|
||||
|
||||
export type DocumentType = 'proposal' | 'expenseReport' | 'expenseEstimate' | 'document';
|
||||
export type DocumentType = 'proposal' | 'expenseReport' | 'expenseEstimate' | 'document' | 'dynamic';
|
||||
|
||||
// 결재자 정보
|
||||
export interface Approver {
|
||||
@@ -8,7 +8,7 @@ export interface Approver {
|
||||
name: string;
|
||||
position: string;
|
||||
department: string;
|
||||
status: 'pending' | 'approved' | 'rejected' | 'none';
|
||||
status: 'pending' | 'approved' | 'rejected' | 'skipped' | 'on_hold' | 'none';
|
||||
approvedAt?: string;
|
||||
}
|
||||
|
||||
@@ -50,7 +50,7 @@ export interface ExpenseReportDocumentData {
|
||||
drafter: Approver;
|
||||
}
|
||||
|
||||
// 지출 예상 내역서 항목
|
||||
// 비용견적서 항목
|
||||
export interface ExpenseEstimateItem {
|
||||
id: string;
|
||||
expectedPaymentDate: string;
|
||||
@@ -60,7 +60,7 @@ export interface ExpenseEstimateItem {
|
||||
account: string;
|
||||
}
|
||||
|
||||
// 지출 예상 내역서 데이터
|
||||
// 비용견적서 데이터
|
||||
export interface ExpenseEstimateDocumentData {
|
||||
documentNo: string;
|
||||
createdAt: string;
|
||||
@@ -72,6 +72,18 @@ export interface ExpenseEstimateDocumentData {
|
||||
drafter: Approver;
|
||||
}
|
||||
|
||||
// 동적 양식 문서 데이터 (API template 기반)
|
||||
export interface DynamicDocumentData {
|
||||
documentNo: string;
|
||||
createdAt: string;
|
||||
formName: string;
|
||||
formCategory?: string;
|
||||
fields: Record<string, unknown>;
|
||||
fieldLabels?: Record<string, string>;
|
||||
approvers: Approver[];
|
||||
drafter: Approver;
|
||||
}
|
||||
|
||||
// 연결 문서 데이터 (검사 성적서, 작업일지 등 문서관리에서 생성된 문서)
|
||||
export interface LinkedDocumentData {
|
||||
documentNo: string;
|
||||
@@ -96,22 +108,26 @@ export interface LinkedDocumentData {
|
||||
}
|
||||
|
||||
// 문서 상세 모달 모드
|
||||
export type DocumentDetailMode = 'draft' | 'inbox' | 'reference';
|
||||
export type DocumentDetailMode = 'draft' | 'inbox' | 'reference' | 'completed';
|
||||
|
||||
// 문서 상태 (기안함 기준)
|
||||
export type DocumentStatus = 'draft' | 'pending' | 'approved' | 'rejected';
|
||||
export type DocumentStatus = 'draft' | 'pending' | 'approved' | 'rejected' | 'cancelled' | 'on_hold';
|
||||
|
||||
// 문서 상세 모달 Props
|
||||
export interface DocumentDetailModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
documentType: DocumentType;
|
||||
data: ProposalDocumentData | ExpenseReportDocumentData | ExpenseEstimateDocumentData | LinkedDocumentData;
|
||||
mode?: DocumentDetailMode; // 'draft': 기안함 (상신), 'inbox': 결재함 (승인/반려)
|
||||
documentStatus?: DocumentStatus; // 문서 상태 (임시저장일 때만 수정/상신 가능)
|
||||
data: ProposalDocumentData | ExpenseReportDocumentData | ExpenseEstimateDocumentData | LinkedDocumentData | DynamicDocumentData;
|
||||
mode?: DocumentDetailMode; // 'draft': 기안함 (상신), 'inbox': 결재함 (승인/반려), 'completed': 완료함
|
||||
documentStatus?: DocumentStatus; // 문서 상태
|
||||
onEdit?: () => void;
|
||||
onCopy?: () => void;
|
||||
onApprove?: () => void;
|
||||
onReject?: () => void;
|
||||
onSubmit?: () => void; // 상신 콜백
|
||||
onHold?: () => void; // 보류
|
||||
onReleaseHold?: () => void; // 보류해제
|
||||
onPreDecide?: () => void; // 전결
|
||||
onCancel?: () => void; // 회수
|
||||
}
|
||||
@@ -85,6 +85,8 @@ function mapApiStatus(apiStatus: string): DocumentStatus {
|
||||
'in_progress': 'inProgress',
|
||||
'approved': 'approved',
|
||||
'rejected': 'rejected',
|
||||
'cancelled': 'cancelled',
|
||||
'on_hold': 'on_hold',
|
||||
};
|
||||
return statusMap[apiStatus] || 'draft';
|
||||
}
|
||||
@@ -97,6 +99,8 @@ function mapApproverStatus(stepStatus: string): Approver['status'] {
|
||||
'pending': 'pending',
|
||||
'approved': 'approved',
|
||||
'rejected': 'rejected',
|
||||
'skipped': 'skipped',
|
||||
'on_hold': 'on_hold',
|
||||
};
|
||||
return statusMap[stepStatus] || 'none';
|
||||
}
|
||||
@@ -156,7 +160,7 @@ function transformApiToFrontend(data: ApprovalApiData): DraftRecord {
|
||||
|
||||
const DRAFT_STATUS_MAP: Record<string, string> = {
|
||||
'draft': 'draft', 'pending': 'pending', 'inProgress': 'in_progress',
|
||||
'approved': 'approved', 'rejected': 'rejected',
|
||||
'approved': 'approved', 'rejected': 'rejected', 'cancelled': 'cancelled', 'on_hold': 'on_hold',
|
||||
};
|
||||
|
||||
// ============================================
|
||||
@@ -255,3 +259,12 @@ export async function cancelDraft(id: string): Promise<ActionResult> {
|
||||
errorMessage: '결재 회수에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
export async function copyApproval(id: string): Promise<ActionResult<{ id: number }>> {
|
||||
return executeServerAction<{ id: number }>({
|
||||
url: buildApiUrl(`/api/v1/approvals/${id}/copy`),
|
||||
method: 'POST',
|
||||
body: {},
|
||||
errorMessage: '문서 복사에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -25,13 +25,6 @@ import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { TableRow, TableCell } from '@/components/ui/table';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import {
|
||||
UniversalListPage,
|
||||
type UniversalListConfig,
|
||||
@@ -45,7 +38,9 @@ import type {
|
||||
ProposalDocumentData,
|
||||
ExpenseReportDocumentData,
|
||||
ExpenseEstimateDocumentData,
|
||||
DynamicDocumentData,
|
||||
} from '@/components/approval/DocumentDetail/types';
|
||||
import { getFieldLabels, filterVisibleFields, getFormName } from '@/components/approval/DocumentDetail/field-labels';
|
||||
import type {
|
||||
DraftRecord,
|
||||
Approver,
|
||||
@@ -319,21 +314,18 @@ export function DraftBox() {
|
||||
|
||||
// ===== 문서 타입 판별 =====
|
||||
const getDocumentType = (item: DraftRecord): DocumentType => {
|
||||
if (item.documentTypeCode) {
|
||||
if (item.documentTypeCode === 'expenseEstimate') return 'expenseEstimate';
|
||||
if (item.documentTypeCode === 'expenseReport') return 'expenseReport';
|
||||
if (item.documentTypeCode === 'proposal') return 'proposal';
|
||||
}
|
||||
if (item.documentType.includes('지출') && item.documentType.includes('예상'))
|
||||
return 'expenseEstimate';
|
||||
if (item.documentType.includes('지출')) return 'expenseReport';
|
||||
return 'proposal';
|
||||
const code = item.documentTypeCode;
|
||||
if (code === 'expenseEstimate' || code === 'expense_estimate') return 'expenseEstimate';
|
||||
if (code === 'expenseReport' || code === 'expense_report') return 'expenseReport';
|
||||
if (code === 'proposal') return 'proposal';
|
||||
// 14개 전용 양식 + 동적 양식 → 'dynamic'
|
||||
return 'dynamic';
|
||||
};
|
||||
|
||||
// ===== 모달용 데이터 변환 =====
|
||||
const convertToModalData = (
|
||||
item: DraftRecord
|
||||
): ProposalDocumentData | ExpenseReportDocumentData | ExpenseEstimateDocumentData => {
|
||||
): ProposalDocumentData | ExpenseReportDocumentData | ExpenseEstimateDocumentData | DynamicDocumentData => {
|
||||
const docType = getDocumentType(item);
|
||||
const content = item.content || {};
|
||||
|
||||
@@ -412,7 +404,20 @@ export function DraftBox() {
|
||||
drafter,
|
||||
};
|
||||
}
|
||||
case 'dynamic': {
|
||||
// 14개 전용 양식 + 동적 양식: DynamicDocumentData
|
||||
return {
|
||||
documentNo: item.documentNo,
|
||||
createdAt: item.draftDate,
|
||||
formName: item.documentType || getFormName(item.documentTypeCode),
|
||||
fields: filterVisibleFields(content),
|
||||
fieldLabels: getFieldLabels(item.documentTypeCode),
|
||||
approvers,
|
||||
drafter,
|
||||
};
|
||||
}
|
||||
default: {
|
||||
// 품의서 (proposal)
|
||||
const files =
|
||||
(content.files as Array<{ id: number; name: string; url?: string }>) || [];
|
||||
const attachmentUrls = files.map((f) => `/api/proxy/files/${f.id}/download`);
|
||||
@@ -585,42 +590,6 @@ export function DraftBox() {
|
||||
</>
|
||||
),
|
||||
|
||||
tableHeaderActions: (
|
||||
<div className="flex items-center gap-2">
|
||||
<Select
|
||||
value={filterOption}
|
||||
onValueChange={(value) => setFilterOption(value as FilterOption)}
|
||||
>
|
||||
<SelectTrigger className="min-w-[120px] w-auto">
|
||||
<SelectValue placeholder="필터 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{FILTER_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select
|
||||
value={sortOption}
|
||||
onValueChange={(value) => setSortOption(value as SortOption)}
|
||||
>
|
||||
<SelectTrigger className="min-w-[120px] w-auto">
|
||||
<SelectValue placeholder="정렬 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SORT_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
),
|
||||
|
||||
renderTableRow: (item, index, globalIndex, handlers) => {
|
||||
const { isSelected, onToggle } = handlers;
|
||||
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
*/
|
||||
|
||||
// ===== 문서 상태 =====
|
||||
export type DocumentStatus = 'draft' | 'pending' | 'inProgress' | 'approved' | 'rejected';
|
||||
export type DocumentStatus = 'draft' | 'pending' | 'inProgress' | 'approved' | 'rejected' | 'cancelled' | 'on_hold';
|
||||
|
||||
// ===== 필터 옵션 =====
|
||||
export type FilterOption = 'all' | 'draft' | 'pending' | 'inProgress' | 'approved' | 'rejected';
|
||||
export type FilterOption = 'all' | 'draft' | 'pending' | 'inProgress' | 'approved' | 'rejected' | 'cancelled' | 'on_hold';
|
||||
|
||||
export const FILTER_OPTIONS: { value: FilterOption; label: string }[] = [
|
||||
{ value: 'all', label: '전체' },
|
||||
@@ -15,6 +15,8 @@ export const FILTER_OPTIONS: { value: FilterOption; label: string }[] = [
|
||||
{ value: 'inProgress', label: '진행중' },
|
||||
{ value: 'approved', label: '완료' },
|
||||
{ value: 'rejected', label: '반려' },
|
||||
{ value: 'cancelled', label: '회수' },
|
||||
{ value: 'on_hold', label: '보류' },
|
||||
];
|
||||
|
||||
// ===== 정렬 옵션 =====
|
||||
@@ -33,7 +35,7 @@ export interface Approver {
|
||||
name: string;
|
||||
position: string;
|
||||
department: string;
|
||||
status: 'pending' | 'approved' | 'rejected' | 'none';
|
||||
status: 'pending' | 'approved' | 'rejected' | 'skipped' | 'on_hold' | 'none';
|
||||
approvedAt?: string;
|
||||
}
|
||||
|
||||
@@ -63,6 +65,8 @@ export const DOCUMENT_STATUS_LABELS: Record<DocumentStatus, string> = {
|
||||
inProgress: '진행중',
|
||||
approved: '완료',
|
||||
rejected: '반려',
|
||||
cancelled: '회수',
|
||||
on_hold: '보류',
|
||||
};
|
||||
|
||||
export const DOCUMENT_STATUS_COLORS: Record<DocumentStatus, string> = {
|
||||
@@ -71,6 +75,8 @@ export const DOCUMENT_STATUS_COLORS: Record<DocumentStatus, string> = {
|
||||
inProgress: 'bg-blue-100 text-blue-800',
|
||||
approved: 'bg-green-100 text-green-800',
|
||||
rejected: 'bg-red-100 text-red-800',
|
||||
cancelled: 'bg-orange-100 text-orange-800',
|
||||
on_hold: 'bg-purple-100 text-purple-800',
|
||||
};
|
||||
|
||||
export const APPROVER_STATUS_COLORS: Record<Approver['status'], string> = {
|
||||
@@ -78,6 +84,8 @@ export const APPROVER_STATUS_COLORS: Record<Approver['status'], string> = {
|
||||
pending: 'bg-yellow-100 text-yellow-800',
|
||||
approved: 'bg-green-100 text-green-800',
|
||||
rejected: 'bg-red-100 text-red-800',
|
||||
skipped: 'bg-gray-100 text-gray-500',
|
||||
on_hold: 'bg-purple-100 text-purple-800',
|
||||
};
|
||||
|
||||
// ===== 기안함 현황 통계 =====
|
||||
|
||||
@@ -50,6 +50,7 @@ function mapApiStatus(apiStatus: string): DocumentStatus {
|
||||
const statusMap: Record<string, DocumentStatus> = {
|
||||
'draft': 'pending', 'pending': 'pending', 'in_progress': 'pending',
|
||||
'approved': 'approved', 'rejected': 'rejected',
|
||||
'cancelled': 'cancelled', 'on_hold': 'on_hold',
|
||||
};
|
||||
return statusMap[apiStatus] || 'pending';
|
||||
}
|
||||
@@ -70,6 +71,8 @@ function transformApiToFrontend(data: ReferenceApiData): ReferenceRecord {
|
||||
id: String(data.id),
|
||||
documentNo: data.document_number,
|
||||
approvalType: mapApprovalType(data.form?.category),
|
||||
formCode: data.form?.code,
|
||||
formName: data.form?.name,
|
||||
title: data.title,
|
||||
draftDate: data.created_at.replace('T', ' ').substring(0, 16),
|
||||
drafter: data.drafter?.name || '',
|
||||
|
||||
@@ -19,13 +19,6 @@ import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { TableRow, TableCell } from '@/components/ui/table';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
|
||||
import {
|
||||
UniversalListPage,
|
||||
@@ -36,7 +29,10 @@ import {
|
||||
import { ListMobileCard, InfoField } from '@/components/organisms/MobileCard';
|
||||
// import { DocumentDetailModal } from '@/components/approval/DocumentDetail';
|
||||
import { DocumentDetailModalV2 as DocumentDetailModal } from '@/components/approval/DocumentDetail';
|
||||
import type { DocumentType, ProposalDocumentData, ExpenseReportDocumentData, ExpenseEstimateDocumentData } from '@/components/approval/DocumentDetail/types';
|
||||
import type { DocumentType, ProposalDocumentData, ExpenseReportDocumentData, ExpenseEstimateDocumentData, DynamicDocumentData } from '@/components/approval/DocumentDetail/types';
|
||||
import { getFieldLabels, filterVisibleFields, getFormName } from '@/components/approval/DocumentDetail/field-labels';
|
||||
import { getApprovalById } from '@/components/approval/DocumentCreate/actions';
|
||||
import { DEDICATED_FORM_CODES } from '@/components/approval/DocumentCreate/types';
|
||||
import type {
|
||||
ReferenceTabType,
|
||||
ReferenceRecord,
|
||||
@@ -82,7 +78,10 @@ export function ReferenceBox() {
|
||||
|
||||
// ===== 문서 상세 모달 상태 =====
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [isModalLoading, setIsModalLoading] = useState(false);
|
||||
const [selectedDocument, setSelectedDocument] = useState<ReferenceRecord | null>(null);
|
||||
const [modalData, setModalData] = useState<ProposalDocumentData | ExpenseReportDocumentData | ExpenseEstimateDocumentData | DynamicDocumentData | null>(null);
|
||||
const [modalDocType, setModalDocType] = useState<DocumentType>('proposal');
|
||||
|
||||
// API 데이터
|
||||
const [data, setData] = useState<ReferenceRecord[]>([]);
|
||||
@@ -284,86 +283,125 @@ export function ReferenceBox() {
|
||||
}, [selectedItems, loadData, loadSummary]);
|
||||
|
||||
// ===== 문서 클릭/상세 보기 핸들러 =====
|
||||
const handleDocumentClick = useCallback((item: ReferenceRecord) => {
|
||||
const handleDocumentClick = useCallback(async (item: ReferenceRecord) => {
|
||||
setSelectedDocument(item);
|
||||
setIsModalLoading(true);
|
||||
setIsModalOpen(true);
|
||||
|
||||
try {
|
||||
const result = await getApprovalById(parseInt(item.id));
|
||||
if (result.success && result.data) {
|
||||
const formData = result.data;
|
||||
const docTypeCode = formData.basicInfo.documentType;
|
||||
|
||||
const drafter = {
|
||||
id: 'drafter-1',
|
||||
name: formData.basicInfo.drafter,
|
||||
position: formData.basicInfo.drafterPosition || '',
|
||||
department: formData.basicInfo.drafterDepartment || '',
|
||||
status: 'approved' as const,
|
||||
};
|
||||
const approvers = formData.approvalLine.map((person, index) => ({
|
||||
id: person.id,
|
||||
name: person.name,
|
||||
position: person.position,
|
||||
department: person.department,
|
||||
status: index === 0 ? ('approved' as const) : ('none' as const),
|
||||
}));
|
||||
|
||||
// 전용 양식 또는 동적 양식 → DynamicDocumentData
|
||||
const isBuiltin = ['proposal', 'expenseReport', 'expense_report', 'expenseEstimate', 'expense_estimate'].includes(docTypeCode);
|
||||
|
||||
if (!isBuiltin) {
|
||||
const dedicatedDataMap: Record<string, unknown> = {
|
||||
officialDocument: formData.officialDocumentData,
|
||||
resignation: formData.resignationData,
|
||||
employmentCert: formData.employmentCertData,
|
||||
careerCert: formData.careerCertData,
|
||||
appointmentCert: formData.appointmentCertData,
|
||||
sealUsage: formData.sealUsageData,
|
||||
leaveNotice1st: formData.leaveNotice1stData,
|
||||
leaveNotice2nd: formData.leaveNotice2ndData,
|
||||
powerOfAttorney: formData.powerOfAttorneyData,
|
||||
boardMinutes: formData.boardMinutesData,
|
||||
quotation: formData.quotationData,
|
||||
};
|
||||
const dedicatedData = dedicatedDataMap[docTypeCode];
|
||||
const fields = dedicatedData
|
||||
? filterVisibleFields(dedicatedData as Record<string, unknown>)
|
||||
: (formData.dynamicFormData || {});
|
||||
|
||||
setModalDocType('dynamic');
|
||||
setModalData({
|
||||
documentNo: formData.basicInfo.documentNo,
|
||||
createdAt: formData.basicInfo.draftDate,
|
||||
formName: formData.basicInfo.formName || getFormName(docTypeCode),
|
||||
fields,
|
||||
fieldLabels: getFieldLabels(docTypeCode),
|
||||
approvers,
|
||||
drafter,
|
||||
});
|
||||
} else if (docTypeCode === 'expenseEstimate' || docTypeCode === 'expense_estimate') {
|
||||
setModalDocType('expenseEstimate');
|
||||
setModalData({
|
||||
documentNo: formData.basicInfo.documentNo,
|
||||
createdAt: formData.basicInfo.draftDate,
|
||||
items: formData.expenseEstimateData?.items.map(i => ({
|
||||
id: i.id, expectedPaymentDate: i.expectedPaymentDate, category: i.category,
|
||||
amount: i.amount, vendor: i.vendor, account: i.memo || '',
|
||||
})) || [],
|
||||
totalExpense: formData.expenseEstimateData?.totalExpense || 0,
|
||||
accountBalance: formData.expenseEstimateData?.accountBalance || 0,
|
||||
finalDifference: formData.expenseEstimateData?.finalDifference || 0,
|
||||
approvers, drafter,
|
||||
});
|
||||
} else if (docTypeCode === 'expenseReport' || docTypeCode === 'expense_report') {
|
||||
setModalDocType('expenseReport');
|
||||
setModalData({
|
||||
documentNo: formData.basicInfo.documentNo,
|
||||
createdAt: formData.basicInfo.draftDate,
|
||||
requestDate: formData.expenseReportData?.requestDate || '',
|
||||
paymentDate: formData.expenseReportData?.paymentDate || '',
|
||||
items: formData.expenseReportData?.items.map((i, idx) => ({
|
||||
id: i.id, no: idx + 1, description: i.description, amount: i.amount, note: i.note,
|
||||
})) || [],
|
||||
cardInfo: formData.expenseReportData?.cardId || '-',
|
||||
totalAmount: formData.expenseReportData?.totalAmount || 0,
|
||||
attachments: [], approvers, drafter,
|
||||
});
|
||||
} else {
|
||||
// 품의서
|
||||
setModalDocType('proposal');
|
||||
const uploadedFileUrls = (formData.proposalData?.uploadedFiles || []).map(f =>
|
||||
`/api/proxy/files/${f.id}/download`
|
||||
);
|
||||
setModalData({
|
||||
documentNo: formData.basicInfo.documentNo,
|
||||
createdAt: formData.basicInfo.draftDate,
|
||||
vendor: formData.proposalData?.vendor || '-',
|
||||
vendorPaymentDate: formData.proposalData?.vendorPaymentDate || '',
|
||||
title: formData.proposalData?.title || item.title,
|
||||
description: formData.proposalData?.description || '-',
|
||||
reason: formData.proposalData?.reason || '-',
|
||||
estimatedCost: formData.proposalData?.estimatedCost || 0,
|
||||
attachments: uploadedFileUrls,
|
||||
approvers, drafter,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
toast.error(result.error || '문서 조회에 실패했습니다.');
|
||||
setIsModalOpen(false);
|
||||
}
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('Failed to load document:', error);
|
||||
toast.error('문서를 불러오는데 실패했습니다.');
|
||||
setIsModalOpen(false);
|
||||
} finally {
|
||||
setIsModalLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// ===== ApprovalType → DocumentType 변환 =====
|
||||
const getDocumentType = (approvalType: ApprovalType): DocumentType => {
|
||||
switch (approvalType) {
|
||||
case 'expense_estimate': return 'expenseEstimate';
|
||||
case 'expense_report': return 'expenseReport';
|
||||
default: return 'proposal';
|
||||
}
|
||||
};
|
||||
|
||||
// ===== ReferenceRecord → 모달용 데이터 변환 =====
|
||||
const convertToModalData = (item: ReferenceRecord): ProposalDocumentData | ExpenseReportDocumentData | ExpenseEstimateDocumentData => {
|
||||
const docType = getDocumentType(item.approvalType);
|
||||
const drafter = {
|
||||
id: 'drafter-1',
|
||||
name: item.drafter,
|
||||
position: item.drafterPosition,
|
||||
department: item.drafterDepartment,
|
||||
status: 'approved' as const,
|
||||
};
|
||||
const approvers = [{
|
||||
id: 'approver-1',
|
||||
name: '결재자',
|
||||
position: '부장',
|
||||
department: '경영지원팀',
|
||||
status: 'approved' as const,
|
||||
}];
|
||||
|
||||
switch (docType) {
|
||||
case 'expenseEstimate':
|
||||
return {
|
||||
documentNo: item.documentNo,
|
||||
createdAt: item.draftDate,
|
||||
items: [
|
||||
{ id: '1', expectedPaymentDate: '2025-11-05', category: '통신 서비스', amount: 550000, vendor: 'KT', account: '국민 123-456-789012 홍길동' },
|
||||
{ id: '2', expectedPaymentDate: '2025-11-12', category: '인건 대행', amount: 2500000, vendor: '에이치알코리아', account: '신한 110-123-456789 (주)에이치알' },
|
||||
],
|
||||
totalExpense: 3050000,
|
||||
accountBalance: 25000000,
|
||||
finalDifference: 21950000,
|
||||
approvers,
|
||||
drafter,
|
||||
};
|
||||
case 'expenseReport':
|
||||
return {
|
||||
documentNo: item.documentNo,
|
||||
createdAt: item.draftDate,
|
||||
requestDate: item.draftDate,
|
||||
paymentDate: item.draftDate,
|
||||
items: [
|
||||
{ id: '1', no: 1, description: '업무용 택시비', amount: 50000, note: '고객사 미팅' },
|
||||
{ id: '2', no: 2, description: '식대', amount: 30000, note: '팀 회식' },
|
||||
],
|
||||
cardInfo: '삼성카드 **** 1234',
|
||||
totalAmount: 80000,
|
||||
attachments: [],
|
||||
approvers,
|
||||
drafter,
|
||||
};
|
||||
default:
|
||||
return {
|
||||
documentNo: item.documentNo,
|
||||
createdAt: item.draftDate,
|
||||
vendor: '거래처',
|
||||
vendorPaymentDate: item.draftDate,
|
||||
title: item.title,
|
||||
description: item.title,
|
||||
reason: '업무상 필요',
|
||||
estimatedCost: 1000000,
|
||||
attachments: [],
|
||||
approvers,
|
||||
drafter,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// ===== 통계 카드 =====
|
||||
const statCards: StatCard[] = useMemo(() => [
|
||||
{ label: '전체', value: `${stats.all}건`, icon: Files, iconColor: 'text-blue-500' },
|
||||
@@ -390,39 +428,6 @@ export function ReferenceBox() {
|
||||
{ key: 'status', label: '상태', className: 'text-center' },
|
||||
], []);
|
||||
|
||||
// ===== 테이블 헤더 액션 (필터 + 정렬 셀렉트) =====
|
||||
const tableHeaderActions = useMemo(() => (
|
||||
<div className="flex items-center gap-2">
|
||||
{/* 필터 셀렉트박스 */}
|
||||
<Select value={filterOption} onValueChange={(value) => setFilterOption(value as FilterOption)}>
|
||||
<SelectTrigger className="min-w-[140px] w-auto">
|
||||
<SelectValue placeholder="필터 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{FILTER_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* 정렬 셀렉트박스 */}
|
||||
<Select value={sortOption} onValueChange={(value) => setSortOption(value as SortOption)}>
|
||||
<SelectTrigger className="min-w-[140px] w-auto">
|
||||
<SelectValue placeholder="정렬 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SORT_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
), [filterOption, sortOption]);
|
||||
|
||||
// ===== UniversalListPage 설정 =====
|
||||
const referenceBoxConfig: UniversalListConfig<ReferenceRecord> = useMemo(() => ({
|
||||
title: '참조함',
|
||||
@@ -475,8 +480,6 @@ export function ReferenceBox() {
|
||||
|
||||
itemsPerPage: itemsPerPage,
|
||||
|
||||
tableHeaderActions: tableHeaderActions,
|
||||
|
||||
// 모바일 필터 설정
|
||||
filterConfig: [
|
||||
{
|
||||
@@ -619,12 +622,15 @@ export function ReferenceBox() {
|
||||
/>
|
||||
|
||||
{/* 문서 상세 모달 */}
|
||||
{selectedDocument && (
|
||||
{selectedDocument && modalData && (
|
||||
<DocumentDetailModal
|
||||
open={isModalOpen}
|
||||
onOpenChange={setIsModalOpen}
|
||||
documentType={getDocumentType(selectedDocument.approvalType)}
|
||||
data={convertToModalData(selectedDocument)}
|
||||
onOpenChange={(open) => {
|
||||
setIsModalOpen(open);
|
||||
if (!open) setModalData(null);
|
||||
}}
|
||||
documentType={modalDocType}
|
||||
data={modalData}
|
||||
mode="reference"
|
||||
/>
|
||||
)}
|
||||
@@ -640,7 +646,8 @@ export function ReferenceBox() {
|
||||
statCards,
|
||||
startDate,
|
||||
endDate,
|
||||
tableHeaderActions,
|
||||
filterOption,
|
||||
sortOption,
|
||||
handleMarkReadClick,
|
||||
handleMarkUnreadClick,
|
||||
handleDocumentClick,
|
||||
@@ -651,8 +658,8 @@ export function ReferenceBox() {
|
||||
handleMarkUnreadConfirm,
|
||||
selectedDocument,
|
||||
isModalOpen,
|
||||
getDocumentType,
|
||||
convertToModalData,
|
||||
modalData,
|
||||
modalDocType,
|
||||
]);
|
||||
|
||||
// 모바일 필터 변경 핸들러
|
||||
|
||||
@@ -9,11 +9,11 @@ export type ReferenceTabType = 'all' | 'read' | 'unread';
|
||||
// 열람 상태
|
||||
export type ReadStatus = 'read' | 'unread';
|
||||
|
||||
// 결재 유형 (문서 종류): 지출결의서, 품의서, 지출예상내역서
|
||||
// 결재 유형 (문서 종류): 지출결의서, 품의서, 비용견적서
|
||||
export type ApprovalType = 'expense_report' | 'proposal' | 'expense_estimate';
|
||||
|
||||
// 문서 상태
|
||||
export type DocumentStatus = 'pending' | 'approved' | 'rejected';
|
||||
export type DocumentStatus = 'pending' | 'approved' | 'rejected' | 'cancelled' | 'on_hold';
|
||||
|
||||
// 필터 옵션
|
||||
export type FilterOption = 'all' | 'expense_report' | 'proposal' | 'expense_estimate';
|
||||
@@ -22,7 +22,7 @@ export const FILTER_OPTIONS: { value: FilterOption; label: string }[] = [
|
||||
{ value: 'all', label: '전체' },
|
||||
{ value: 'expense_report', label: '지출결의서' },
|
||||
{ value: 'proposal', label: '품의서' },
|
||||
{ value: 'expense_estimate', label: '지출예상내역서' },
|
||||
{ value: 'expense_estimate', label: '비용견적서' },
|
||||
];
|
||||
|
||||
// 정렬 옵션
|
||||
@@ -40,6 +40,8 @@ export interface ReferenceRecord {
|
||||
id: string;
|
||||
documentNo: string; // 문서번호
|
||||
approvalType: ApprovalType; // 문서유형
|
||||
formCode?: string; // 양식코드 (official_letter, resignation 등)
|
||||
formName?: string; // 양식명 (공문서, 사직서 등)
|
||||
title: string; // 제목
|
||||
draftDate: string; // 기안일시
|
||||
drafter: string; // 기안자
|
||||
@@ -63,19 +65,23 @@ export const REFERENCE_TAB_LABELS: Record<ReferenceTabType, string> = {
|
||||
export const APPROVAL_TYPE_LABELS: Record<ApprovalType, string> = {
|
||||
expense_report: '지출결의서',
|
||||
proposal: '품의서',
|
||||
expense_estimate: '지출예상내역서',
|
||||
expense_estimate: '비용견적서',
|
||||
};
|
||||
|
||||
export const DOCUMENT_STATUS_LABELS: Record<DocumentStatus, string> = {
|
||||
pending: '진행중',
|
||||
approved: '완료',
|
||||
rejected: '반려',
|
||||
cancelled: '회수',
|
||||
on_hold: '보류',
|
||||
};
|
||||
|
||||
export const DOCUMENT_STATUS_COLORS: Record<DocumentStatus, string> = {
|
||||
pending: 'bg-yellow-100 text-yellow-800',
|
||||
approved: 'bg-green-100 text-green-800',
|
||||
rejected: 'bg-red-100 text-red-800',
|
||||
cancelled: 'bg-orange-100 text-orange-800',
|
||||
on_hold: 'bg-purple-100 text-purple-800',
|
||||
};
|
||||
|
||||
export const READ_STATUS_LABELS: Record<ReadStatus, string> = {
|
||||
|
||||
314
src/components/approval_backup_v1/ApprovalBox/actions.ts
Normal file
314
src/components/approval_backup_v1/ApprovalBox/actions.ts
Normal file
@@ -0,0 +1,314 @@
|
||||
/**
|
||||
* 결재함 서버 액션
|
||||
*
|
||||
* API Endpoints:
|
||||
* - GET /api/v1/approvals/inbox - 결재함 목록 조회
|
||||
* - GET /api/v1/approvals/inbox/summary - 결재함 통계
|
||||
* - POST /api/v1/approvals/{id}/approve - 승인 처리
|
||||
* - POST /api/v1/approvals/{id}/reject - 반려 처리
|
||||
*/
|
||||
|
||||
'use server';
|
||||
|
||||
|
||||
import { executeServerAction, type ActionResult } from '@/lib/api/execute-server-action';
|
||||
import { buildApiUrl } from '@/lib/api/query-params';
|
||||
import type { PaginatedApiResponse } from '@/lib/api/types';
|
||||
import type { ApprovalRecord, ApprovalType, ApprovalStatus } from './types';
|
||||
|
||||
// ============================================
|
||||
// API 응답 타입 정의
|
||||
// ============================================
|
||||
|
||||
interface InboxSummary {
|
||||
total: number;
|
||||
pending: number;
|
||||
approved: number;
|
||||
rejected: number;
|
||||
}
|
||||
|
||||
interface InboxApiData {
|
||||
id: number;
|
||||
document_number: string;
|
||||
title: string;
|
||||
status: string;
|
||||
form?: { id: number; name: string; code: string; category: string };
|
||||
drafter?: { id: number; name: string; position?: string; department?: { name: string } };
|
||||
steps?: InboxStepApiData[];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
interface InboxStepApiData {
|
||||
id: number;
|
||||
step_order: number;
|
||||
step_type: string;
|
||||
approver_id: number;
|
||||
approver?: { id: number; name: string; position?: string; department?: { name: string } };
|
||||
status: string;
|
||||
processed_at?: string;
|
||||
comment?: string;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 헬퍼 함수
|
||||
// ============================================
|
||||
|
||||
function mapApiStatus(apiStatus: string): ApprovalStatus {
|
||||
const statusMap: Record<string, ApprovalStatus> = {
|
||||
'pending': 'pending', 'approved': 'approved', 'rejected': 'rejected',
|
||||
};
|
||||
return statusMap[apiStatus] || 'pending';
|
||||
}
|
||||
|
||||
function mapTabToApiStatus(tabStatus: string): string | undefined {
|
||||
const statusMap: Record<string, string> = {
|
||||
'pending': 'requested', 'approved': 'completed', 'rejected': 'rejected',
|
||||
};
|
||||
return statusMap[tabStatus];
|
||||
}
|
||||
|
||||
function mapApprovalType(formCategory?: string): ApprovalType {
|
||||
const typeMap: Record<string, ApprovalType> = {
|
||||
'expense_report': 'expense_report', 'proposal': 'proposal', 'expense_estimate': 'expense_estimate', '문서': 'document', 'document': 'document',
|
||||
};
|
||||
return typeMap[formCategory || ''] || 'proposal';
|
||||
}
|
||||
|
||||
function mapDocumentStatus(status: string): string {
|
||||
const statusMap: Record<string, string> = {
|
||||
'pending': '진행중', 'approved': '완료', 'rejected': '반려',
|
||||
};
|
||||
return statusMap[status] || '진행중';
|
||||
}
|
||||
|
||||
function transformApiToFrontend(data: InboxApiData): ApprovalRecord {
|
||||
const currentStep = data.steps?.find(s => s.step_type === 'approval' || s.step_type === 'agreement');
|
||||
const approver = currentStep?.approver;
|
||||
const stepStatus = currentStep?.status || 'pending';
|
||||
|
||||
return {
|
||||
id: String(data.id),
|
||||
documentNo: data.document_number,
|
||||
approvalType: mapApprovalType(data.form?.category),
|
||||
documentStatus: mapDocumentStatus(data.status),
|
||||
title: data.title,
|
||||
draftDate: data.created_at.replace('T', ' ').substring(0, 16),
|
||||
drafter: data.drafter?.name || '',
|
||||
drafterDepartment: data.drafter?.department?.name || '',
|
||||
drafterPosition: data.drafter?.position || '',
|
||||
approvalDate: currentStep?.processed_at?.replace('T', ' ').substring(0, 16),
|
||||
approver: approver?.name,
|
||||
status: mapApiStatus(stepStatus),
|
||||
priority: 'normal',
|
||||
createdAt: data.created_at,
|
||||
updatedAt: data.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// API 함수
|
||||
// ============================================
|
||||
|
||||
export async function getInbox(params?: {
|
||||
page?: number; per_page?: number; search?: string; status?: string;
|
||||
approval_type?: string; sort_by?: string; sort_dir?: 'asc' | 'desc';
|
||||
start_date?: string; end_date?: string;
|
||||
}): Promise<{ data: ApprovalRecord[]; total: number; lastPage: number; __authError?: boolean }> {
|
||||
const result = await executeServerAction<PaginatedApiResponse<InboxApiData>>({
|
||||
url: buildApiUrl('/api/v1/approvals/inbox', {
|
||||
page: params?.page,
|
||||
per_page: params?.per_page,
|
||||
search: params?.search,
|
||||
status: params?.status && params.status !== 'all' ? mapTabToApiStatus(params.status) : undefined,
|
||||
approval_type: params?.approval_type !== 'all' ? params?.approval_type : undefined,
|
||||
sort_by: params?.sort_by,
|
||||
sort_dir: params?.sort_dir,
|
||||
start_date: params?.start_date,
|
||||
end_date: params?.end_date,
|
||||
}),
|
||||
errorMessage: '결재함 목록 조회에 실패했습니다.',
|
||||
});
|
||||
|
||||
if (result.__authError) return { data: [], total: 0, lastPage: 1, __authError: true };
|
||||
if (!result.success || !result.data?.data) return { data: [], total: 0, lastPage: 1 };
|
||||
|
||||
return {
|
||||
data: result.data.data.map(transformApiToFrontend),
|
||||
total: result.data.total,
|
||||
lastPage: result.data.last_page,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getInboxSummary(): Promise<InboxSummary | null> {
|
||||
const result = await executeServerAction<InboxSummary>({
|
||||
url: buildApiUrl('/api/v1/approvals/inbox/summary'),
|
||||
errorMessage: '결재함 통계 조회에 실패했습니다.',
|
||||
});
|
||||
return result.success ? result.data || null : null;
|
||||
}
|
||||
|
||||
export async function approveDocument(id: string, comment?: string): Promise<ActionResult> {
|
||||
return executeServerAction({
|
||||
url: buildApiUrl(`/api/v1/approvals/${id}/approve`),
|
||||
method: 'POST',
|
||||
body: { comment: comment || '' },
|
||||
errorMessage: '승인 처리에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
export async function rejectDocument(id: string, comment: string): Promise<ActionResult> {
|
||||
if (!comment?.trim()) return { success: false, error: '반려 사유를 입력해주세요.' };
|
||||
return executeServerAction({
|
||||
url: buildApiUrl(`/api/v1/approvals/${id}/reject`),
|
||||
method: 'POST',
|
||||
body: { comment },
|
||||
errorMessage: '반려 처리에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
export async function approveDocumentsBulk(ids: string[], comment?: string): Promise<{ success: boolean; failedIds?: string[]; error?: string }> {
|
||||
const failedIds: string[] = [];
|
||||
for (const id of ids) {
|
||||
const result = await approveDocument(id, comment);
|
||||
if (!result.success) failedIds.push(id);
|
||||
}
|
||||
if (failedIds.length > 0) {
|
||||
return { success: false, failedIds, error: `${failedIds.length}건의 승인 처리에 실패했습니다.` };
|
||||
}
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 연결 문서(Document) 조회
|
||||
// ============================================
|
||||
|
||||
interface LinkedDocumentApiData {
|
||||
id: number;
|
||||
document_number: string;
|
||||
title: string;
|
||||
status: string;
|
||||
drafter?: {
|
||||
id: number; name: string; position?: string;
|
||||
department?: { name: string };
|
||||
tenant_profile?: { position_key?: string; department?: { name: string } };
|
||||
};
|
||||
steps?: InboxStepApiData[];
|
||||
linkable?: {
|
||||
id: number;
|
||||
title: string;
|
||||
document_no: string;
|
||||
status: string;
|
||||
created_at: string;
|
||||
linkable_type?: string;
|
||||
linkable_id?: number;
|
||||
template?: { id: number; name: string; code: string };
|
||||
data?: Array<{ id: number; field_key: string; field_label?: string; field_value?: unknown; value?: unknown }>;
|
||||
approvals?: Array<{ id: number; step: number; status: string; acted_at?: string; user?: { id: number; name: string } }>;
|
||||
attachments?: Array<{ id: number; display_name: string; file_path: string }>;
|
||||
};
|
||||
}
|
||||
|
||||
interface LinkedDocumentResult {
|
||||
documentNo: string;
|
||||
createdAt: string;
|
||||
title: string;
|
||||
templateName: string;
|
||||
templateCode: string;
|
||||
status: string;
|
||||
workOrderId?: number;
|
||||
documentData: Array<{ fieldKey: string; fieldLabel: string; value: unknown }>;
|
||||
approvers: Array<{ id: string; name: string; position: string; department: string; status: 'pending' | 'approved' | 'rejected' | 'none' }>;
|
||||
drafter: { id: string; name: string; position: string; department: string; status: 'approved' | 'pending' | 'rejected' | 'none' };
|
||||
attachments?: Array<{ id: number; name: string; url: string }>;
|
||||
}
|
||||
|
||||
function getPositionLabel(positionKey: string | null | undefined): string {
|
||||
if (!positionKey) return '';
|
||||
const labels: Record<string, string> = {
|
||||
'EXECUTIVE': '임원', 'DIRECTOR': '부장', 'MANAGER': '과장',
|
||||
'SENIOR': '대리', 'STAFF': '사원', 'INTERN': '인턴',
|
||||
};
|
||||
return labels[positionKey] ?? positionKey;
|
||||
}
|
||||
|
||||
export async function getDocumentApprovalById(id: number): Promise<{
|
||||
success: boolean;
|
||||
data?: LinkedDocumentResult;
|
||||
error?: string;
|
||||
}> {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const result = await executeServerAction<any>({
|
||||
url: buildApiUrl(`/api/v1/approvals/${id}`),
|
||||
errorMessage: '문서 조회에 실패했습니다.',
|
||||
});
|
||||
if (!result.success || !result.data) return { success: false, error: result.error };
|
||||
|
||||
const apiData = result.data as LinkedDocumentApiData;
|
||||
const linkable = apiData.linkable;
|
||||
|
||||
const drafter = {
|
||||
id: String(apiData.drafter?.id || ''),
|
||||
name: apiData.drafter?.name || '',
|
||||
position: apiData.drafter?.tenant_profile?.position_key
|
||||
? getPositionLabel(apiData.drafter.tenant_profile.position_key)
|
||||
: (apiData.drafter?.position || ''),
|
||||
department: apiData.drafter?.tenant_profile?.department?.name
|
||||
|| apiData.drafter?.department?.name || '',
|
||||
status: 'approved' as const,
|
||||
};
|
||||
|
||||
const approvers = (apiData.steps || [])
|
||||
.filter(s => s.step_type === 'approval' || s.step_type === 'agreement')
|
||||
.map(step => ({
|
||||
id: String(step.approver?.id || step.approver_id),
|
||||
name: step.approver?.name || '',
|
||||
position: step.approver?.position || '',
|
||||
department: step.approver?.department?.name || '',
|
||||
status: (step.status === 'approved' ? 'approved'
|
||||
: step.status === 'rejected' ? 'rejected'
|
||||
: step.status === 'pending' ? 'pending'
|
||||
: 'none') as 'pending' | 'approved' | 'rejected' | 'none',
|
||||
}));
|
||||
|
||||
// work_order 연결 문서인 경우 workOrderId 추출
|
||||
const workOrderId = linkable?.linkable_type === 'work_order' ? linkable.linkable_id : undefined;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
documentNo: linkable?.document_no || apiData.document_number,
|
||||
createdAt: linkable?.created_at || '',
|
||||
title: linkable?.title || apiData.title,
|
||||
templateName: linkable?.template?.name || '',
|
||||
templateCode: linkable?.template?.code || '',
|
||||
status: linkable?.status || apiData.status,
|
||||
workOrderId,
|
||||
documentData: (linkable?.data || []).map(d => ({
|
||||
fieldKey: d.field_key,
|
||||
fieldLabel: d.field_label || d.field_key,
|
||||
value: d.field_value ?? d.value,
|
||||
})),
|
||||
approvers,
|
||||
drafter,
|
||||
attachments: (linkable?.attachments || []).map(a => ({
|
||||
id: a.id,
|
||||
name: a.display_name,
|
||||
url: `/api/proxy/files/${a.id}/download`,
|
||||
})),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function rejectDocumentsBulk(ids: string[], comment: string): Promise<{ success: boolean; failedIds?: string[]; error?: string }> {
|
||||
if (!comment?.trim()) return { success: false, error: '반려 사유를 입력해주세요.' };
|
||||
const failedIds: string[] = [];
|
||||
for (const id of ids) {
|
||||
const result = await rejectDocument(id, comment);
|
||||
if (!result.success) failedIds.push(id);
|
||||
}
|
||||
if (failedIds.length > 0) {
|
||||
return { success: false, failedIds, error: `${failedIds.length}건의 반려 처리에 실패했습니다.` };
|
||||
}
|
||||
return { success: true };
|
||||
}
|
||||
900
src/components/approval_backup_v1/ApprovalBox/index.tsx
Normal file
900
src/components/approval_backup_v1/ApprovalBox/index.tsx
Normal file
@@ -0,0 +1,900 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useMemo, useCallback, useEffect, useTransition, useRef } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useDateRange } from '@/hooks';
|
||||
import {
|
||||
FileCheck,
|
||||
Check,
|
||||
X,
|
||||
Clock,
|
||||
FileX,
|
||||
Files,
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
getInbox,
|
||||
approveDocument,
|
||||
rejectDocument,
|
||||
approveDocumentsBulk,
|
||||
rejectDocumentsBulk,
|
||||
getDocumentApprovalById,
|
||||
} from './actions';
|
||||
import { getApprovalById } from '@/components/approval/DocumentCreate/actions';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { TableRow, TableCell } from '@/components/ui/table';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
|
||||
import {
|
||||
UniversalListPage,
|
||||
type UniversalListConfig,
|
||||
type TabOption,
|
||||
} from '@/components/templates/UniversalListPage';
|
||||
import { ListMobileCard, InfoField } from '@/components/organisms/MobileCard';
|
||||
// import { DocumentDetailModal } from '@/components/approval/DocumentDetail';
|
||||
import { DocumentDetailModalV2 as DocumentDetailModal } from '@/components/approval/DocumentDetail';
|
||||
import type {
|
||||
DocumentType,
|
||||
ProposalDocumentData,
|
||||
ExpenseReportDocumentData,
|
||||
ExpenseEstimateDocumentData,
|
||||
LinkedDocumentData,
|
||||
} from '@/components/approval/DocumentDetail/types';
|
||||
import type {
|
||||
ApprovalTabType,
|
||||
ApprovalRecord,
|
||||
ApprovalType,
|
||||
SortOption,
|
||||
FilterOption,
|
||||
} from './types';
|
||||
import {
|
||||
APPROVAL_TAB_LABELS,
|
||||
SORT_OPTIONS,
|
||||
FILTER_OPTIONS,
|
||||
APPROVAL_TYPE_LABELS,
|
||||
APPROVAL_STATUS_LABELS,
|
||||
APPROVAL_STATUS_COLORS,
|
||||
} from './types';
|
||||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||
import { usePermission } from '@/hooks/usePermission';
|
||||
import { InspectionReportModal } from '@/components/production/WorkOrders/documents/InspectionReportModal';
|
||||
|
||||
export function ApprovalBox() {
|
||||
const router = useRouter();
|
||||
const [, startTransition] = useTransition();
|
||||
const { canApprove } = usePermission();
|
||||
|
||||
// ===== 상태 관리 =====
|
||||
const [activeTab, setActiveTab] = useState<ApprovalTabType>('all');
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [filterOption, setFilterOption] = useState<FilterOption>('all');
|
||||
const [sortOption, setSortOption] = useState<SortOption>('latest');
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const itemsPerPage = 20;
|
||||
|
||||
// 날짜 범위 상태
|
||||
const { startDate, endDate, setStartDate, setEndDate } = useDateRange('currentYear');
|
||||
|
||||
// 다이얼로그 상태
|
||||
const [approveDialogOpen, setApproveDialogOpen] = useState(false);
|
||||
const [rejectDialogOpen, setRejectDialogOpen] = useState(false);
|
||||
const [rejectComment, setRejectComment] = useState('');
|
||||
const [pendingSelectedItems, setPendingSelectedItems] = useState<Set<string>>(new Set());
|
||||
const [pendingClearSelection, setPendingClearSelection] = useState<(() => void) | null>(null);
|
||||
|
||||
// ===== 문서 상세 모달 상태 =====
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [selectedDocument, setSelectedDocument] = useState<ApprovalRecord | null>(null);
|
||||
const [modalData, setModalData] = useState<ProposalDocumentData | ExpenseReportDocumentData | ExpenseEstimateDocumentData | LinkedDocumentData | null>(null);
|
||||
const [, setIsModalLoading] = useState(false);
|
||||
|
||||
// ===== 검사성적서 모달 상태 (work_order 연결 문서용) =====
|
||||
const [isInspectionModalOpen, setIsInspectionModalOpen] = useState(false);
|
||||
const [inspectionWorkOrderId, setInspectionWorkOrderId] = useState<string | null>(null);
|
||||
|
||||
// API 데이터
|
||||
const [data, setData] = useState<ApprovalRecord[]>([]);
|
||||
const [totalCount, setTotalCount] = useState(0);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const isInitialLoadDone = useRef(false);
|
||||
|
||||
// 통계 데이터
|
||||
const [fixedStats, setFixedStats] = useState({ all: 0, pending: 0, approved: 0, rejected: 0 });
|
||||
|
||||
// ===== 데이터 로드 =====
|
||||
const loadData = useCallback(async () => {
|
||||
if (!isInitialLoadDone.current) {
|
||||
setIsLoading(true);
|
||||
}
|
||||
try {
|
||||
const sortConfig: { sort_by: string; sort_dir: 'asc' | 'desc' } = (() => {
|
||||
switch (sortOption) {
|
||||
case 'latest':
|
||||
return { sort_by: 'created_at', sort_dir: 'desc' };
|
||||
case 'oldest':
|
||||
return { sort_by: 'created_at', sort_dir: 'asc' };
|
||||
case 'draftDateAsc':
|
||||
return { sort_by: 'created_at', sort_dir: 'asc' };
|
||||
case 'draftDateDesc':
|
||||
return { sort_by: 'created_at', sort_dir: 'desc' };
|
||||
default:
|
||||
return { sort_by: 'created_at', sort_dir: 'desc' };
|
||||
}
|
||||
})();
|
||||
|
||||
const result = await getInbox({
|
||||
page: currentPage,
|
||||
per_page: itemsPerPage,
|
||||
search: searchQuery || undefined,
|
||||
status: activeTab !== 'all' ? activeTab : undefined,
|
||||
approval_type: filterOption !== 'all' ? filterOption : undefined,
|
||||
start_date: startDate || undefined,
|
||||
end_date: endDate || undefined,
|
||||
...sortConfig,
|
||||
});
|
||||
|
||||
setData(result.data);
|
||||
setTotalCount(result.total);
|
||||
setTotalPages(result.lastPage);
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('Failed to load inbox:', error);
|
||||
toast.error('결재함 목록을 불러오는데 실패했습니다.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
isInitialLoadDone.current = true;
|
||||
}
|
||||
}, [currentPage, itemsPerPage, searchQuery, filterOption, sortOption, activeTab, startDate, endDate]);
|
||||
|
||||
// ===== 초기 로드 =====
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [loadData]);
|
||||
|
||||
// ===== 검색어/필터/탭 변경 시 페이지 초기화 =====
|
||||
useEffect(() => {
|
||||
setCurrentPage(1);
|
||||
}, [searchQuery, filterOption, sortOption, activeTab]);
|
||||
|
||||
// ===== 탭 변경 핸들러 =====
|
||||
const handleTabChange = useCallback((value: string) => {
|
||||
setActiveTab(value as ApprovalTabType);
|
||||
setSearchQuery('');
|
||||
}, []);
|
||||
|
||||
// ===== 전체 탭일 때만 통계 업데이트 =====
|
||||
useEffect(() => {
|
||||
if (activeTab === 'all' && data.length > 0) {
|
||||
const pending = data.filter((item) => item.status === 'pending').length;
|
||||
const approved = data.filter((item) => item.status === 'approved').length;
|
||||
const rejected = data.filter((item) => item.status === 'rejected').length;
|
||||
|
||||
setFixedStats({
|
||||
all: totalCount,
|
||||
pending,
|
||||
approved,
|
||||
rejected,
|
||||
});
|
||||
}
|
||||
}, [data, totalCount, activeTab]);
|
||||
|
||||
// ===== 승인/반려 핸들러 =====
|
||||
const handleApproveClick = useCallback(
|
||||
(selectedItems: Set<string>, onClearSelection: () => void) => {
|
||||
if (selectedItems.size === 0) return;
|
||||
setPendingSelectedItems(selectedItems);
|
||||
setPendingClearSelection(() => onClearSelection);
|
||||
setApproveDialogOpen(true);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleApproveConfirm = useCallback(async () => {
|
||||
const ids = Array.from(pendingSelectedItems);
|
||||
|
||||
startTransition(async () => {
|
||||
try {
|
||||
const result = await approveDocumentsBulk(ids);
|
||||
if (result.success) {
|
||||
toast.success('승인 완료', {
|
||||
description: '결재 승인이 완료되었습니다.',
|
||||
});
|
||||
pendingClearSelection?.();
|
||||
loadData();
|
||||
} else {
|
||||
toast.error(result.error || '승인 처리에 실패했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('Approve error:', error);
|
||||
toast.error('승인 처리 중 오류가 발생했습니다.');
|
||||
}
|
||||
});
|
||||
|
||||
setApproveDialogOpen(false);
|
||||
setPendingSelectedItems(new Set());
|
||||
setPendingClearSelection(null);
|
||||
}, [pendingSelectedItems, pendingClearSelection, loadData]);
|
||||
|
||||
const handleRejectClick = useCallback(
|
||||
(selectedItems: Set<string>, onClearSelection: () => void) => {
|
||||
if (selectedItems.size === 0) return;
|
||||
setPendingSelectedItems(selectedItems);
|
||||
setPendingClearSelection(() => onClearSelection);
|
||||
setRejectComment('');
|
||||
setRejectDialogOpen(true);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleRejectConfirm = useCallback(async () => {
|
||||
if (!rejectComment.trim()) {
|
||||
toast.error('반려 사유를 입력해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
const ids = Array.from(pendingSelectedItems);
|
||||
|
||||
startTransition(async () => {
|
||||
try {
|
||||
const result = await rejectDocumentsBulk(ids, rejectComment);
|
||||
if (result.success) {
|
||||
toast.success('반려 완료', {
|
||||
description: '결재 반려가 완료되었습니다.',
|
||||
});
|
||||
pendingClearSelection?.();
|
||||
setRejectComment('');
|
||||
loadData();
|
||||
} else {
|
||||
toast.error(result.error || '반려 처리에 실패했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('Reject error:', error);
|
||||
toast.error('반려 처리 중 오류가 발생했습니다.');
|
||||
}
|
||||
});
|
||||
|
||||
setRejectDialogOpen(false);
|
||||
setPendingSelectedItems(new Set());
|
||||
setPendingClearSelection(null);
|
||||
}, [pendingSelectedItems, rejectComment, pendingClearSelection, loadData]);
|
||||
|
||||
// ===== 문서 클릭 핸들러 =====
|
||||
const handleDocumentClick = useCallback(async (item: ApprovalRecord) => {
|
||||
setSelectedDocument(item);
|
||||
setIsModalLoading(true);
|
||||
setIsModalOpen(true);
|
||||
|
||||
try {
|
||||
// 문서 결재(document) 타입은 별도 API로 연결 문서 데이터 조회
|
||||
if (item.approvalType === 'document') {
|
||||
const result = await getDocumentApprovalById(parseInt(item.id));
|
||||
if (result.success && result.data) {
|
||||
// work_order 연결 문서 → InspectionReportModal로 열기
|
||||
if (result.data.workOrderId) {
|
||||
setIsModalOpen(false);
|
||||
setIsModalLoading(false);
|
||||
setInspectionWorkOrderId(String(result.data.workOrderId));
|
||||
setIsInspectionModalOpen(true);
|
||||
return;
|
||||
}
|
||||
setModalData(result.data as LinkedDocumentData);
|
||||
} else {
|
||||
toast.error(result.error || '문서 조회에 실패했습니다.');
|
||||
setIsModalOpen(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 기존 결재 문서 타입 (품의서, 지출결의서, 지출예상내역서)
|
||||
const result = await getApprovalById(parseInt(item.id));
|
||||
if (result.success && result.data) {
|
||||
const formData = result.data;
|
||||
const docType = getDocumentType(item.approvalType);
|
||||
|
||||
// 기안자 정보
|
||||
const drafter = {
|
||||
id: 'drafter-1',
|
||||
name: formData.basicInfo.drafter,
|
||||
position: formData.basicInfo.drafterPosition || '',
|
||||
department: formData.basicInfo.drafterDepartment || '',
|
||||
status: 'approved' as const,
|
||||
};
|
||||
|
||||
// 결재자 정보
|
||||
const approvers = formData.approvalLine.map((person, index) => ({
|
||||
id: person.id,
|
||||
name: person.name,
|
||||
position: person.position,
|
||||
department: person.department,
|
||||
status:
|
||||
item.status === 'approved'
|
||||
? ('approved' as const)
|
||||
: item.status === 'rejected'
|
||||
? ('rejected' as const)
|
||||
: index === 0
|
||||
? ('pending' as const)
|
||||
: ('none' as const),
|
||||
}));
|
||||
|
||||
let convertedData: ProposalDocumentData | ExpenseReportDocumentData | ExpenseEstimateDocumentData;
|
||||
|
||||
switch (docType) {
|
||||
case 'expenseEstimate':
|
||||
convertedData = {
|
||||
documentNo: formData.basicInfo.documentNo,
|
||||
createdAt: formData.basicInfo.draftDate,
|
||||
items: formData.expenseEstimateData?.items.map(item => ({
|
||||
id: item.id,
|
||||
expectedPaymentDate: item.expectedPaymentDate,
|
||||
category: item.category,
|
||||
amount: item.amount,
|
||||
vendor: item.vendor,
|
||||
account: item.memo || '',
|
||||
})) || [],
|
||||
totalExpense: formData.expenseEstimateData?.totalExpense || 0,
|
||||
accountBalance: formData.expenseEstimateData?.accountBalance || 0,
|
||||
finalDifference: formData.expenseEstimateData?.finalDifference || 0,
|
||||
approvers,
|
||||
drafter,
|
||||
};
|
||||
break;
|
||||
case 'expenseReport':
|
||||
convertedData = {
|
||||
documentNo: formData.basicInfo.documentNo,
|
||||
createdAt: formData.basicInfo.draftDate,
|
||||
requestDate: formData.expenseReportData?.requestDate || '',
|
||||
paymentDate: formData.expenseReportData?.paymentDate || '',
|
||||
items: formData.expenseReportData?.items.map((item, index) => ({
|
||||
id: item.id,
|
||||
no: index + 1,
|
||||
description: item.description,
|
||||
amount: item.amount,
|
||||
note: item.note,
|
||||
})) || [],
|
||||
cardInfo: formData.expenseReportData?.cardId || '-',
|
||||
totalAmount: formData.expenseReportData?.totalAmount || 0,
|
||||
attachments: formData.expenseReportData?.uploadedFiles?.map(f => f.name) || [],
|
||||
approvers,
|
||||
drafter,
|
||||
};
|
||||
break;
|
||||
default: {
|
||||
// 품의서
|
||||
const uploadedFileUrls = (formData.proposalData?.uploadedFiles || []).map(f =>
|
||||
`/api/proxy/files/${f.id}/download`
|
||||
);
|
||||
convertedData = {
|
||||
documentNo: formData.basicInfo.documentNo,
|
||||
createdAt: formData.basicInfo.draftDate,
|
||||
vendor: formData.proposalData?.vendor || '-',
|
||||
vendorPaymentDate: formData.proposalData?.vendorPaymentDate || '',
|
||||
title: formData.proposalData?.title || item.title,
|
||||
description: formData.proposalData?.description || '-',
|
||||
reason: formData.proposalData?.reason || '-',
|
||||
estimatedCost: formData.proposalData?.estimatedCost || 0,
|
||||
attachments: uploadedFileUrls,
|
||||
approvers,
|
||||
drafter,
|
||||
};
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
setModalData(convertedData);
|
||||
} else {
|
||||
toast.error(result.error || '문서 조회에 실패했습니다.');
|
||||
setIsModalOpen(false);
|
||||
}
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('Failed to load document:', error);
|
||||
toast.error('문서를 불러오는데 실패했습니다.');
|
||||
setIsModalOpen(false);
|
||||
} finally {
|
||||
setIsModalLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleModalEdit = useCallback(() => {
|
||||
if (selectedDocument) {
|
||||
router.push(`/ko/approval/draft/new?id=${selectedDocument.id}&mode=edit`);
|
||||
setIsModalOpen(false);
|
||||
}
|
||||
}, [selectedDocument, router]);
|
||||
|
||||
const handleModalCopy = useCallback(() => {
|
||||
toast.info('문서 복제 기능은 준비 중입니다.');
|
||||
setIsModalOpen(false);
|
||||
}, []);
|
||||
|
||||
const handleModalApprove = useCallback(async () => {
|
||||
if (!selectedDocument?.id) return;
|
||||
const result = await approveDocument(selectedDocument.id);
|
||||
if (result.success) {
|
||||
toast.success('문서가 승인되었습니다.');
|
||||
loadData();
|
||||
} else {
|
||||
toast.error(result.error || '승인에 실패했습니다.');
|
||||
}
|
||||
setIsModalOpen(false);
|
||||
}, [selectedDocument, loadData]);
|
||||
|
||||
const handleModalReject = useCallback(async () => {
|
||||
if (!selectedDocument?.id) return;
|
||||
const result = await rejectDocument(selectedDocument.id, '반려');
|
||||
if (result.success) {
|
||||
toast.success('문서가 반려되었습니다.');
|
||||
loadData();
|
||||
} else {
|
||||
toast.error(result.error || '반려에 실패했습니다.');
|
||||
}
|
||||
setIsModalOpen(false);
|
||||
}, [selectedDocument, loadData]);
|
||||
|
||||
// ===== 문서 타입 변환 =====
|
||||
const getDocumentType = (approvalType: ApprovalType): DocumentType => {
|
||||
switch (approvalType) {
|
||||
case 'expense_estimate':
|
||||
return 'expenseEstimate';
|
||||
case 'expense_report':
|
||||
return 'expenseReport';
|
||||
case 'document':
|
||||
return 'document';
|
||||
default:
|
||||
return 'proposal';
|
||||
}
|
||||
};
|
||||
// ===== 탭 옵션 =====
|
||||
const tabs: TabOption[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
value: 'all',
|
||||
label: APPROVAL_TAB_LABELS.all,
|
||||
count: fixedStats.all,
|
||||
color: 'blue',
|
||||
},
|
||||
{
|
||||
value: 'pending',
|
||||
label: APPROVAL_TAB_LABELS.pending,
|
||||
count: fixedStats.pending,
|
||||
color: 'yellow',
|
||||
},
|
||||
{
|
||||
value: 'approved',
|
||||
label: APPROVAL_TAB_LABELS.approved,
|
||||
count: fixedStats.approved,
|
||||
color: 'green',
|
||||
},
|
||||
{
|
||||
value: 'rejected',
|
||||
label: APPROVAL_TAB_LABELS.rejected,
|
||||
count: fixedStats.rejected,
|
||||
color: 'red',
|
||||
},
|
||||
],
|
||||
[fixedStats]
|
||||
);
|
||||
|
||||
// ===== UniversalListPage 설정 =====
|
||||
const approvalBoxConfig: UniversalListConfig<ApprovalRecord> = useMemo(
|
||||
() => ({
|
||||
title: '결재함',
|
||||
description: '결재 문서를 관리합니다',
|
||||
icon: FileCheck,
|
||||
basePath: '/approval/inbox',
|
||||
|
||||
idField: 'id',
|
||||
|
||||
actions: {
|
||||
getList: async () => ({
|
||||
success: true,
|
||||
data: data,
|
||||
totalCount: totalCount,
|
||||
totalPages: totalPages,
|
||||
}),
|
||||
},
|
||||
|
||||
columns: [
|
||||
{ key: 'no', label: '번호', className: 'w-[60px] text-center' },
|
||||
{ key: 'documentNo', label: '문서번호', copyable: true },
|
||||
{ key: 'approvalType', label: '문서유형', copyable: true },
|
||||
{ key: 'title', label: '제목', copyable: true },
|
||||
{ key: 'drafter', label: '기안자', copyable: true },
|
||||
{ key: 'approver', label: '결재자', copyable: true },
|
||||
{ key: 'draftDate', label: '기안일시', copyable: true },
|
||||
{ key: 'status', label: '상태', className: 'text-center' },
|
||||
],
|
||||
|
||||
tabs: tabs,
|
||||
defaultTab: activeTab,
|
||||
|
||||
// 검색창 (공통 컴포넌트에서 자동 생성)
|
||||
hideSearch: true,
|
||||
searchValue: searchQuery,
|
||||
onSearchChange: setSearchQuery,
|
||||
|
||||
dateRangeSelector: {
|
||||
enabled: true,
|
||||
showPresets: true,
|
||||
startDate,
|
||||
endDate,
|
||||
onStartDateChange: setStartDate,
|
||||
onEndDateChange: setEndDate,
|
||||
},
|
||||
|
||||
searchPlaceholder: '제목, 기안자, 부서 검색...',
|
||||
searchFilter: (item: ApprovalRecord, search: string) => {
|
||||
const s = search.toLowerCase();
|
||||
return (
|
||||
item.title?.toLowerCase().includes(s) ||
|
||||
item.drafter?.toLowerCase().includes(s) ||
|
||||
item.drafterDepartment?.toLowerCase().includes(s) ||
|
||||
false
|
||||
);
|
||||
},
|
||||
|
||||
itemsPerPage: itemsPerPage,
|
||||
|
||||
// 모바일 필터 설정
|
||||
filterConfig: [
|
||||
{
|
||||
key: 'approvalType',
|
||||
label: '문서유형',
|
||||
type: 'single',
|
||||
options: FILTER_OPTIONS.filter((o) => o.value !== 'all'),
|
||||
},
|
||||
{
|
||||
key: 'sort',
|
||||
label: '정렬',
|
||||
type: 'single',
|
||||
options: SORT_OPTIONS,
|
||||
},
|
||||
],
|
||||
initialFilters: {
|
||||
approvalType: filterOption,
|
||||
sort: sortOption,
|
||||
},
|
||||
filterTitle: '결재함 필터',
|
||||
|
||||
computeStats: () => [
|
||||
{
|
||||
label: '전체결재',
|
||||
value: `${fixedStats.all}건`,
|
||||
icon: Files,
|
||||
iconColor: 'text-blue-500',
|
||||
},
|
||||
{
|
||||
label: '미결재',
|
||||
value: `${fixedStats.pending}건`,
|
||||
icon: Clock,
|
||||
iconColor: 'text-yellow-500',
|
||||
},
|
||||
{
|
||||
label: '결재완료',
|
||||
value: `${fixedStats.approved}건`,
|
||||
icon: FileCheck,
|
||||
iconColor: 'text-green-500',
|
||||
},
|
||||
{
|
||||
label: '결재반려',
|
||||
value: `${fixedStats.rejected}건`,
|
||||
icon: FileX,
|
||||
iconColor: 'text-red-500',
|
||||
},
|
||||
],
|
||||
|
||||
selectionActions: ({ selectedItems, onClearSelection }) => canApprove ? (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="default"
|
||||
onClick={() => handleApproveClick(selectedItems, onClearSelection)}
|
||||
>
|
||||
<Check className="h-4 w-4 mr-2" />
|
||||
승인
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={() => handleRejectClick(selectedItems, onClearSelection)}
|
||||
>
|
||||
<X className="h-4 w-4 mr-2" />
|
||||
반려
|
||||
</Button>
|
||||
</>
|
||||
) : null,
|
||||
|
||||
tableHeaderActions: (
|
||||
<div className="flex items-center gap-2">
|
||||
<Select
|
||||
value={filterOption}
|
||||
onValueChange={(value) => setFilterOption(value as FilterOption)}
|
||||
>
|
||||
<SelectTrigger className="min-w-[140px] w-auto">
|
||||
<SelectValue placeholder="필터 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{FILTER_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select
|
||||
value={sortOption}
|
||||
onValueChange={(value) => setSortOption(value as SortOption)}
|
||||
>
|
||||
<SelectTrigger className="min-w-[140px] w-auto">
|
||||
<SelectValue placeholder="정렬 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SORT_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
),
|
||||
|
||||
renderTableRow: (item, index, globalIndex, handlers) => {
|
||||
const { isSelected, onToggle } = handlers;
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
key={item.id}
|
||||
className="hover:bg-muted/50 cursor-pointer"
|
||||
onClick={() => handleDocumentClick(item)}
|
||||
>
|
||||
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
|
||||
<Checkbox checked={isSelected} onCheckedChange={onToggle} />
|
||||
</TableCell>
|
||||
<TableCell className="text-center">{globalIndex}</TableCell>
|
||||
<TableCell className="font-mono text-sm">{item.documentNo}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline">{APPROVAL_TYPE_LABELS[item.approvalType]}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="font-medium max-w-[200px] truncate">
|
||||
{item.title}
|
||||
</TableCell>
|
||||
<TableCell>{item.drafter}</TableCell>
|
||||
<TableCell>{item.approver || '-'}</TableCell>
|
||||
<TableCell>{item.draftDate}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Badge className={APPROVAL_STATUS_COLORS[item.status]}>
|
||||
{APPROVAL_STATUS_LABELS[item.status]}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
},
|
||||
|
||||
renderMobileCard: (item, index, globalIndex, handlers) => {
|
||||
const { isSelected, onToggle } = handlers;
|
||||
|
||||
return (
|
||||
<ListMobileCard
|
||||
id={item.id}
|
||||
title={item.title}
|
||||
headerBadges={
|
||||
<div className="flex gap-1">
|
||||
<Badge variant="outline">{APPROVAL_TYPE_LABELS[item.approvalType]}</Badge>
|
||||
<Badge className={APPROVAL_STATUS_COLORS[item.status]}>
|
||||
{APPROVAL_STATUS_LABELS[item.status]}
|
||||
</Badge>
|
||||
</div>
|
||||
}
|
||||
isSelected={isSelected}
|
||||
onToggleSelection={onToggle}
|
||||
infoGrid={
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<InfoField label="문서번호" value={item.documentNo} />
|
||||
<InfoField label="기안자" value={item.drafter} />
|
||||
<InfoField label="부서" value={item.drafterDepartment} />
|
||||
<InfoField label="직급" value={item.drafterPosition} />
|
||||
<InfoField label="기안일" value={item.draftDate} />
|
||||
<InfoField label="결재일" value={item.approvalDate || '-'} />
|
||||
</div>
|
||||
}
|
||||
actions={
|
||||
item.status === 'pending' && isSelected && canApprove ? (
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="default"
|
||||
className="flex-1"
|
||||
onClick={() =>
|
||||
handleApproveClick(new Set([item.id]), () => {})
|
||||
}
|
||||
>
|
||||
<Check className="w-4 h-4 mr-2" /> 승인
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
onClick={() =>
|
||||
handleRejectClick(new Set([item.id]), () => {})
|
||||
}
|
||||
>
|
||||
<X className="w-4 h-4 mr-2" /> 반려
|
||||
</Button>
|
||||
</div>
|
||||
) : undefined
|
||||
}
|
||||
onClick={() => handleDocumentClick(item)}
|
||||
/>
|
||||
);
|
||||
},
|
||||
|
||||
renderDialogs: () => (
|
||||
<>
|
||||
{/* 승인 확인 다이얼로그 */}
|
||||
<ConfirmDialog
|
||||
open={approveDialogOpen}
|
||||
onOpenChange={setApproveDialogOpen}
|
||||
onConfirm={handleApproveConfirm}
|
||||
title="결재 승인"
|
||||
description={`정말 ${pendingSelectedItems.size}건을 승인하시겠습니까?`}
|
||||
variant="success"
|
||||
confirmText="승인"
|
||||
/>
|
||||
|
||||
{/* 반려 확인 다이얼로그 */}
|
||||
<AlertDialog open={rejectDialogOpen} onOpenChange={setRejectDialogOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>결재 반려</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{pendingSelectedItems.size}건의 결재를 반려합니다. 반려 사유를
|
||||
입력해주세요.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<div className="py-4">
|
||||
<Label htmlFor="reject-comment" className="text-sm font-medium">
|
||||
반려 사유 <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Textarea
|
||||
id="reject-comment"
|
||||
placeholder="반려 사유를 입력해주세요..."
|
||||
value={rejectComment}
|
||||
onChange={(e) => setRejectComment(e.target.value)}
|
||||
className="mt-2 min-h-[100px]"
|
||||
/>
|
||||
</div>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel onClick={() => setRejectComment('')}>
|
||||
취소
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleRejectConfirm}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
disabled={!rejectComment.trim()}
|
||||
>
|
||||
반려
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{/* 문서 상세 모달 */}
|
||||
{selectedDocument && modalData && (
|
||||
<DocumentDetailModal
|
||||
open={isModalOpen}
|
||||
onOpenChange={(open) => {
|
||||
setIsModalOpen(open);
|
||||
if (!open) {
|
||||
setModalData(null);
|
||||
}
|
||||
}}
|
||||
documentType={getDocumentType(selectedDocument.approvalType)}
|
||||
data={modalData}
|
||||
mode="inbox"
|
||||
onEdit={handleModalEdit}
|
||||
onCopy={handleModalCopy}
|
||||
onApprove={canApprove ? handleModalApprove : undefined}
|
||||
onReject={canApprove ? handleModalReject : undefined}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 검사성적서 모달 (work_order 연결 문서) */}
|
||||
<InspectionReportModal
|
||||
open={isInspectionModalOpen}
|
||||
onOpenChange={(open) => {
|
||||
setIsInspectionModalOpen(open);
|
||||
if (!open) {
|
||||
setInspectionWorkOrderId(null);
|
||||
}
|
||||
}}
|
||||
workOrderId={inspectionWorkOrderId}
|
||||
readOnly={true}
|
||||
/>
|
||||
</>
|
||||
),
|
||||
}),
|
||||
[
|
||||
data,
|
||||
totalCount,
|
||||
totalPages,
|
||||
tabs,
|
||||
activeTab,
|
||||
startDate,
|
||||
endDate,
|
||||
fixedStats,
|
||||
handleApproveClick,
|
||||
handleRejectClick,
|
||||
filterOption,
|
||||
sortOption,
|
||||
handleDocumentClick,
|
||||
approveDialogOpen,
|
||||
pendingSelectedItems,
|
||||
handleApproveConfirm,
|
||||
rejectDialogOpen,
|
||||
rejectComment,
|
||||
handleRejectConfirm,
|
||||
selectedDocument,
|
||||
isModalOpen,
|
||||
modalData,
|
||||
handleModalEdit,
|
||||
handleModalCopy,
|
||||
handleModalApprove,
|
||||
handleModalReject,
|
||||
canApprove,
|
||||
isInspectionModalOpen,
|
||||
inspectionWorkOrderId,
|
||||
]
|
||||
);
|
||||
|
||||
// 모바일 필터 변경 핸들러
|
||||
const handleMobileFilterChange = useCallback((filters: Record<string, string | string[]>) => {
|
||||
if (filters.approvalType) {
|
||||
setFilterOption(filters.approvalType as FilterOption);
|
||||
}
|
||||
if (filters.sort) {
|
||||
setSortOption(filters.sort as SortOption);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<UniversalListPage<ApprovalRecord>
|
||||
config={approvalBoxConfig}
|
||||
initialData={data}
|
||||
initialTotalCount={totalCount}
|
||||
externalPagination={{
|
||||
currentPage,
|
||||
totalPages,
|
||||
totalItems: totalCount,
|
||||
itemsPerPage,
|
||||
onPageChange: setCurrentPage,
|
||||
}}
|
||||
onTabChange={handleTabChange}
|
||||
onSearchChange={setSearchQuery}
|
||||
onFilterChange={handleMobileFilterChange}
|
||||
externalIsLoading={isLoading}
|
||||
/>
|
||||
);
|
||||
}
|
||||
95
src/components/approval_backup_v1/ApprovalBox/types.ts
Normal file
95
src/components/approval_backup_v1/ApprovalBox/types.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* 결재함 타입 정의
|
||||
* 4개 메인 탭: 전체결재, 미결재, 결재완료, 결재반려
|
||||
*/
|
||||
|
||||
// ===== 메인 탭 타입 =====
|
||||
export type ApprovalTabType = 'all' | 'pending' | 'approved' | 'rejected';
|
||||
|
||||
// 결재 상태
|
||||
export type ApprovalStatus = 'pending' | 'approved' | 'rejected';
|
||||
|
||||
// 결재 유형 (문서 종류): 지출결의서, 품의서, 지출예상내역서, 문서결재
|
||||
export type ApprovalType = 'expense_report' | 'proposal' | 'expense_estimate' | 'document';
|
||||
|
||||
// 필터 옵션
|
||||
export type FilterOption = 'all' | 'expense_report' | 'proposal' | 'expense_estimate' | 'document';
|
||||
|
||||
export const FILTER_OPTIONS: { value: FilterOption; label: string }[] = [
|
||||
{ value: 'all', label: '전체' },
|
||||
{ value: 'expense_report', label: '지출결의서' },
|
||||
{ value: 'proposal', label: '품의서' },
|
||||
{ value: 'expense_estimate', label: '지출예상내역서' },
|
||||
{ value: 'document', label: '문서 결재' },
|
||||
];
|
||||
|
||||
// 정렬 옵션
|
||||
export type SortOption = 'latest' | 'oldest' | 'draftDateAsc' | 'draftDateDesc';
|
||||
|
||||
export const SORT_OPTIONS: { value: SortOption; label: string }[] = [
|
||||
{ value: 'latest', label: '최신순' },
|
||||
{ value: 'oldest', label: '오래된순' },
|
||||
{ value: 'draftDateAsc', label: '기안일 오름차순' },
|
||||
{ value: 'draftDateDesc', label: '기안일 내림차순' },
|
||||
];
|
||||
|
||||
// ===== 결재 문서 레코드 =====
|
||||
export interface ApprovalRecord {
|
||||
id: string;
|
||||
documentNo: string; // 문서번호
|
||||
approvalType: ApprovalType; // 결재유형 (휴가, 경비 등)
|
||||
documentStatus: string; // 문서상태
|
||||
title: string; // 제목
|
||||
draftDate: string; // 기안일
|
||||
drafter: string; // 기안자
|
||||
drafterDepartment: string; // 기안자 부서
|
||||
drafterPosition: string; // 기안자 직급
|
||||
approvalDate?: string; // 결재일
|
||||
approver?: string; // 결재자
|
||||
status: ApprovalStatus; // 결재 상태
|
||||
priority?: 'high' | 'normal' | 'low'; // 우선순위
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
// ===== 폼 데이터 =====
|
||||
export interface ApprovalFormData {
|
||||
documentId: string;
|
||||
action: 'approve' | 'reject';
|
||||
comment?: string;
|
||||
}
|
||||
|
||||
// ===== 상수 정의 =====
|
||||
|
||||
export const APPROVAL_TAB_LABELS: Record<ApprovalTabType, string> = {
|
||||
all: '전체결재',
|
||||
pending: '미결재',
|
||||
approved: '결재완료',
|
||||
rejected: '결재반려',
|
||||
};
|
||||
|
||||
export const APPROVAL_TYPE_LABELS: Record<ApprovalType, string> = {
|
||||
expense_report: '지출결의서',
|
||||
proposal: '품의서',
|
||||
expense_estimate: '지출예상내역서',
|
||||
document: '문서 결재',
|
||||
};
|
||||
|
||||
export const APPROVAL_TYPE_COLORS: Record<ApprovalType, string> = {
|
||||
expense_report: 'blue',
|
||||
proposal: 'green',
|
||||
expense_estimate: 'purple',
|
||||
document: 'orange',
|
||||
};
|
||||
|
||||
export const APPROVAL_STATUS_LABELS: Record<ApprovalStatus, string> = {
|
||||
pending: '대기',
|
||||
approved: '승인',
|
||||
rejected: '반려',
|
||||
};
|
||||
|
||||
export const APPROVAL_STATUS_COLORS: Record<ApprovalStatus, string> = {
|
||||
pending: 'bg-yellow-100 text-yellow-800',
|
||||
approved: 'bg-green-100 text-green-800',
|
||||
rejected: 'bg-red-100 text-red-800',
|
||||
};
|
||||
@@ -0,0 +1,108 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Plus, Trash2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import type { ApprovalPerson } from './types';
|
||||
import { getEmployees } from './actions';
|
||||
|
||||
interface ApprovalLineSectionProps {
|
||||
data: ApprovalPerson[];
|
||||
onChange: (data: ApprovalPerson[]) => void;
|
||||
}
|
||||
|
||||
export function ApprovalLineSection({ data, onChange }: ApprovalLineSectionProps) {
|
||||
const [employees, setEmployees] = useState<ApprovalPerson[]>([]);
|
||||
|
||||
// 직원 목록 로드
|
||||
useEffect(() => {
|
||||
getEmployees().then(setEmployees);
|
||||
}, []);
|
||||
|
||||
const handleAdd = () => {
|
||||
const newPerson: ApprovalPerson = {
|
||||
id: `temp-${Date.now()}`,
|
||||
department: '',
|
||||
position: '',
|
||||
name: '',
|
||||
};
|
||||
onChange([...data, newPerson]);
|
||||
};
|
||||
|
||||
const handleRemove = (index: number) => {
|
||||
onChange(data.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const handleChange = (index: number, employeeId: string) => {
|
||||
const employee = employees.find((e) => e.id === employeeId);
|
||||
if (employee) {
|
||||
const newData = [...data];
|
||||
newData[index] = { ...employee };
|
||||
onChange(newData);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg border p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold">결재선</h3>
|
||||
<Button variant="outline" size="sm" onClick={handleAdd}>
|
||||
<Plus className="w-4 h-4 mr-1" />
|
||||
추가
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="text-sm text-gray-500 mb-2">부서 / 직책 / 이름</div>
|
||||
|
||||
{data.length === 0 ? (
|
||||
<div className="text-center py-4 text-gray-400">
|
||||
결재선을 추가해주세요
|
||||
</div>
|
||||
) : (
|
||||
data.map((person, index) => (
|
||||
<div key={`${person.id}-${index}`} className="flex items-center gap-2">
|
||||
<span className="w-8 text-center text-sm text-gray-500">{index + 1}</span>
|
||||
<Select
|
||||
value={person.id.startsWith('temp-') ? undefined : person.id}
|
||||
onValueChange={(value) => handleChange(index, value)}
|
||||
>
|
||||
<SelectTrigger className="flex-1">
|
||||
<SelectValue
|
||||
placeholder={
|
||||
person.name && !person.id.startsWith('temp-')
|
||||
? `${person.department || ''} / ${person.position || ''} / ${person.name}`
|
||||
: "부서명 / 직책명 / 이름 ▼"
|
||||
}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{employees.map((employee) => (
|
||||
<SelectItem key={employee.id} value={employee.id}>
|
||||
{employee.department || ''} / {employee.position || ''} / {employee.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-9 w-9 text-red-500 hover:text-red-700 hover:bg-red-50"
|
||||
onClick={() => handleRemove(index)}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
'use client';
|
||||
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import type { BasicInfo, DocumentType } from './types';
|
||||
import { DOCUMENT_TYPE_OPTIONS } from './types';
|
||||
|
||||
interface BasicInfoSectionProps {
|
||||
data: BasicInfo;
|
||||
onChange: (data: BasicInfo) => void;
|
||||
}
|
||||
|
||||
export function BasicInfoSection({ data, onChange }: BasicInfoSectionProps) {
|
||||
return (
|
||||
<div className="bg-white rounded-lg border p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">기본 정보</h3>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* 기안자 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="drafter">기안자</Label>
|
||||
<Input
|
||||
id="drafter"
|
||||
value={data.drafter}
|
||||
disabled
|
||||
className="bg-gray-50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 작성일 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="draftDate">작성일</Label>
|
||||
<Input
|
||||
id="draftDate"
|
||||
value={data.draftDate}
|
||||
disabled
|
||||
className="bg-gray-50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 문서번호 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="documentNo">문서번호</Label>
|
||||
<Input
|
||||
id="documentNo"
|
||||
placeholder="문서번호를 입력해주세요"
|
||||
value={data.documentNo}
|
||||
onChange={(e) => onChange({ ...data, documentNo: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 문서유형 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="documentType">문서유형</Label>
|
||||
<Select
|
||||
value={data.documentType}
|
||||
onValueChange={(value) => onChange({ ...data, documentType: value as DocumentType })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="문서유형 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{DOCUMENT_TYPE_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
'use client';
|
||||
|
||||
import { Fragment } from 'react';
|
||||
import { ContentSkeleton } from '@/components/ui/skeleton';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import type { ExpenseEstimateData, ExpenseEstimateItem } from './types';
|
||||
import { formatNumber as formatCurrency } from '@/lib/utils/amount';
|
||||
|
||||
interface ExpenseEstimateFormProps {
|
||||
data: ExpenseEstimateData;
|
||||
onChange: (data: ExpenseEstimateData) => void;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export function ExpenseEstimateForm({ data, onChange, isLoading }: ExpenseEstimateFormProps) {
|
||||
const items = data.items;
|
||||
|
||||
const handleCheckChange = (id: string, checked: boolean) => {
|
||||
const newItems = items.map((item) =>
|
||||
item.id === id ? { ...item, checked } : item
|
||||
);
|
||||
const totalExpense = newItems.reduce((sum, item) => sum + item.amount, 0);
|
||||
onChange({
|
||||
...data,
|
||||
items: newItems,
|
||||
totalExpense,
|
||||
finalDifference: data.accountBalance - totalExpense,
|
||||
});
|
||||
};
|
||||
|
||||
// 월별 그룹핑
|
||||
const groupedByMonth = items.reduce((acc, item) => {
|
||||
const month = item.expectedPaymentDate.substring(0, 7); // YYYY-MM
|
||||
if (!acc[month]) {
|
||||
acc[month] = [];
|
||||
}
|
||||
acc[month].push(item);
|
||||
return acc;
|
||||
}, {} as Record<string, ExpenseEstimateItem[]>);
|
||||
|
||||
const getMonthSubtotal = (monthItems: ExpenseEstimateItem[]) => {
|
||||
return monthItems.reduce((sum, item) => sum + item.amount, 0);
|
||||
};
|
||||
|
||||
const totalExpense = items.reduce((sum, item) => sum + item.amount, 0);
|
||||
const accountBalance = data.accountBalance || 10000000; // Mock 계좌 잔액
|
||||
const finalDifference = accountBalance - totalExpense;
|
||||
|
||||
// 로딩 상태
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="bg-white rounded-lg border p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">지출 예상 내역서 목록</h3>
|
||||
<ContentSkeleton type="table" rows={5} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 빈 상태
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="bg-white rounded-lg border p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">지출 예상 내역서 목록</h3>
|
||||
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
|
||||
<p>등록된 지출 예상 항목이 없습니다.</p>
|
||||
<p className="text-sm mt-1">지출 예상 항목을 먼저 등록해주세요.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 지출 예상 내역서 정보 */}
|
||||
<div className="bg-white rounded-lg border p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">지출 예상 내역서 목록</h3>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[50px] text-center"></TableHead>
|
||||
<TableHead className="min-w-[120px]">예상 지급일</TableHead>
|
||||
<TableHead className="min-w-[150px]">항목</TableHead>
|
||||
<TableHead className="min-w-[120px] text-right">지출금액</TableHead>
|
||||
<TableHead className="min-w-[100px]">거래처</TableHead>
|
||||
<TableHead className="min-w-[150px]">적록</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{Object.entries(groupedByMonth).map(([month, monthItems]) => (
|
||||
<Fragment key={month}>
|
||||
{monthItems.map((item) => (
|
||||
<TableRow key={item.id}>
|
||||
<TableCell className="text-center">
|
||||
<Checkbox
|
||||
checked={item.checked}
|
||||
onCheckedChange={(checked) => handleCheckChange(item.id, !!checked)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>{item.expectedPaymentDate}</TableCell>
|
||||
<TableCell>{item.category}</TableCell>
|
||||
<TableCell className="text-right text-blue-600 font-medium">
|
||||
{formatCurrency(item.amount)}
|
||||
</TableCell>
|
||||
<TableCell>{item.vendor}</TableCell>
|
||||
<TableCell>{item.memo}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{/* 월별 소계 */}
|
||||
<TableRow className="bg-pink-50">
|
||||
<TableCell colSpan={2} className="font-medium">
|
||||
{month.replace('-', '년 ')}월 계
|
||||
</TableCell>
|
||||
<TableCell></TableCell>
|
||||
<TableCell className="text-right text-red-600 font-bold">
|
||||
{formatCurrency(getMonthSubtotal(monthItems))}
|
||||
</TableCell>
|
||||
<TableCell colSpan={2}></TableCell>
|
||||
</TableRow>
|
||||
</Fragment>
|
||||
))}
|
||||
|
||||
{/* 합계 행들 */}
|
||||
<TableRow className="bg-gray-50 border-t-2">
|
||||
<TableCell colSpan={3} className="font-semibold">지출 합계</TableCell>
|
||||
<TableCell className="text-right text-red-600 font-bold">
|
||||
{formatCurrency(totalExpense)}
|
||||
</TableCell>
|
||||
<TableCell colSpan={2}></TableCell>
|
||||
</TableRow>
|
||||
<TableRow className="bg-gray-50">
|
||||
<TableCell colSpan={3} className="font-semibold">계좌 잔액</TableCell>
|
||||
<TableCell className="text-right font-bold">
|
||||
{formatCurrency(accountBalance)}
|
||||
</TableCell>
|
||||
<TableCell colSpan={2}></TableCell>
|
||||
</TableRow>
|
||||
<TableRow className="bg-gray-50">
|
||||
<TableCell colSpan={3} className="font-semibold">최종 차액</TableCell>
|
||||
<TableCell className={`text-right font-bold ${finalDifference >= 0 ? 'text-blue-600' : 'text-red-600'}`}>
|
||||
{formatCurrency(finalDifference)}
|
||||
</TableCell>
|
||||
<TableCell colSpan={2}></TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
'use client';
|
||||
|
||||
import { Plus, Trash2 } from 'lucide-react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { DatePicker } from '@/components/ui/date-picker';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { CurrencyInput } from '@/components/ui/currency-input';
|
||||
import { FileDropzone } from '@/components/ui/file-dropzone';
|
||||
import { FileList, type NewFile, type ExistingFile } from '@/components/ui/file-list';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import type { ExpenseReportData, ExpenseReportItem } from './types';
|
||||
import { CARD_OPTIONS } from './types';
|
||||
import { formatNumber as formatCurrency } from '@/lib/utils/amount';
|
||||
|
||||
interface ExpenseReportFormProps {
|
||||
data: ExpenseReportData;
|
||||
onChange: (data: ExpenseReportData) => void;
|
||||
}
|
||||
|
||||
export function ExpenseReportForm({ data, onChange }: ExpenseReportFormProps) {
|
||||
const handleAddItem = () => {
|
||||
const newItem: ExpenseReportItem = {
|
||||
id: `item-${Date.now()}`,
|
||||
description: '',
|
||||
amount: 0,
|
||||
note: '',
|
||||
};
|
||||
onChange({ ...data, items: [...data.items, newItem] });
|
||||
};
|
||||
|
||||
const handleRemoveItem = (index: number) => {
|
||||
const newItems = data.items.filter((_, i) => i !== index);
|
||||
const totalAmount = newItems.reduce((sum, item) => sum + item.amount, 0);
|
||||
onChange({ ...data, items: newItems, totalAmount });
|
||||
};
|
||||
|
||||
const handleItemChange = (index: number, field: keyof ExpenseReportItem, value: string | number) => {
|
||||
const newItems = [...data.items];
|
||||
newItems[index] = { ...newItems[index], [field]: value };
|
||||
const totalAmount = newItems.reduce((sum, item) => sum + item.amount, 0);
|
||||
onChange({ ...data, items: newItems, totalAmount });
|
||||
};
|
||||
|
||||
const handleFilesSelect = (files: File[]) => {
|
||||
onChange({ ...data, attachments: [...data.attachments, ...files] });
|
||||
};
|
||||
|
||||
// 기존 업로드 파일 삭제
|
||||
const handleRemoveUploadedFile = (fileId: number) => {
|
||||
const updatedFiles = (data.uploadedFiles || []).filter((f) => f.id !== fileId);
|
||||
onChange({ ...data, uploadedFiles: updatedFiles });
|
||||
};
|
||||
|
||||
// 새 첨부 파일 삭제
|
||||
const handleRemoveAttachment = (index: number) => {
|
||||
const updatedAttachments = data.attachments.filter((_, i) => i !== index);
|
||||
onChange({ ...data, attachments: updatedAttachments });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 지출 정보 */}
|
||||
<div className="bg-white rounded-lg border p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">지출 정보</h3>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="requestDate">지출 요청일</Label>
|
||||
<DatePicker
|
||||
value={data.requestDate}
|
||||
onChange={(date) => onChange({ ...data, requestDate: date })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="paymentDate">결제일</Label>
|
||||
<DatePicker
|
||||
value={data.paymentDate}
|
||||
onChange={(date) => onChange({ ...data, paymentDate: date })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 지출결의서 정보 */}
|
||||
<div className="bg-white rounded-lg border p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold">지출결의서 정보</h3>
|
||||
<Button variant="outline" size="sm" onClick={handleAddItem}>
|
||||
<Plus className="w-4 h-4 mr-1" />
|
||||
추가
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[60px] text-center">번호</TableHead>
|
||||
<TableHead className="min-w-[200px]">적요</TableHead>
|
||||
<TableHead className="min-w-[150px]">금액</TableHead>
|
||||
<TableHead className="min-w-[150px]">비고</TableHead>
|
||||
<TableHead className="w-[60px] text-center">삭제</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data.items.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="text-center py-8 text-gray-400">
|
||||
항목을 추가해주세요
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
data.items.map((item, index) => (
|
||||
<TableRow key={item.id}>
|
||||
<TableCell className="text-center">{index + 1}</TableCell>
|
||||
<TableCell>
|
||||
<Input
|
||||
placeholder="적요를 입력해주세요"
|
||||
value={item.description}
|
||||
onChange={(e) => handleItemChange(index, 'description', e.target.value)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<CurrencyInput
|
||||
placeholder="금액을 입력해주세요"
|
||||
value={item.amount || 0}
|
||||
onChange={(value) => handleItemChange(index, 'amount', value ?? 0)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Input
|
||||
placeholder="비고를 입력해주세요"
|
||||
value={item.note}
|
||||
onChange={(e) => handleItemChange(index, 'note', e.target.value)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-red-500 hover:text-red-700 hover:bg-red-50"
|
||||
onClick={() => handleRemoveItem(index)}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 결제 정보 */}
|
||||
<div className="bg-white rounded-lg border p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">결제 정보</h3>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="card">카드</Label>
|
||||
<Select
|
||||
value={data.cardId}
|
||||
onValueChange={(value) => onChange({ ...data, cardId: value })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="카드를 선택해주세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{CARD_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>총 비용</Label>
|
||||
<div className="h-10 px-3 py-2 bg-gray-50 border rounded-md text-right font-semibold">
|
||||
{formatCurrency(data.totalAmount)}원
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 참고 이미지 정보 */}
|
||||
<div className="bg-white rounded-lg border p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">참고 이미지 정보</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>첨부파일</Label>
|
||||
<FileDropzone
|
||||
onFilesSelect={handleFilesSelect}
|
||||
multiple
|
||||
accept="image/*"
|
||||
maxSize={10}
|
||||
compact
|
||||
title="클릭하거나 파일을 드래그하세요"
|
||||
description="이미지 파일만 업로드 가능합니다"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 파일 목록 */}
|
||||
<FileList
|
||||
files={data.attachments.map((file): NewFile => ({ file }))}
|
||||
existingFiles={(data.uploadedFiles || []).map((file): ExistingFile => ({
|
||||
id: file.id,
|
||||
name: file.name,
|
||||
url: file.url,
|
||||
size: file.size,
|
||||
}))}
|
||||
onRemove={handleRemoveAttachment}
|
||||
onRemoveExisting={(id) => handleRemoveUploadedFile(id as number)}
|
||||
emptyMessage="첨부된 파일이 없습니다"
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Mic } from 'lucide-react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { DatePicker } from '@/components/ui/date-picker';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { CurrencyInput } from '@/components/ui/currency-input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { FileDropzone } from '@/components/ui/file-dropzone';
|
||||
import { FileList, type NewFile, type ExistingFile } from '@/components/ui/file-list';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { getClients } from '@/components/accounting/VendorManagement/actions';
|
||||
import type { ProposalData } from './types';
|
||||
|
||||
// 거래처 옵션 타입
|
||||
interface ClientOption {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface ProposalFormProps {
|
||||
data: ProposalData;
|
||||
onChange: (data: ProposalData) => void;
|
||||
}
|
||||
|
||||
export function ProposalForm({ data, onChange }: ProposalFormProps) {
|
||||
// 거래처 목록 상태
|
||||
const [clients, setClients] = useState<ClientOption[]>([]);
|
||||
const [isLoadingClients, setIsLoadingClients] = useState(true);
|
||||
|
||||
// 거래처 목록 로드 (매입 거래처만)
|
||||
useEffect(() => {
|
||||
async function loadClients() {
|
||||
setIsLoadingClients(true);
|
||||
const result = await getClients({ size: 1000, only_active: true });
|
||||
if (result.success) {
|
||||
// 매입 거래처(purchase, both)만 필터링
|
||||
const purchaseClients = result.data
|
||||
.filter((v) => v.category === 'purchase' || v.category === 'both')
|
||||
.map((v) => ({
|
||||
id: v.id,
|
||||
name: v.vendorName,
|
||||
}));
|
||||
setClients(purchaseClients);
|
||||
}
|
||||
setIsLoadingClients(false);
|
||||
}
|
||||
loadClients();
|
||||
}, []);
|
||||
|
||||
// 거래처 선택 핸들러
|
||||
const handleVendorChange = (vendorId: string) => {
|
||||
const selected = clients.find((c) => c.id === vendorId);
|
||||
onChange({
|
||||
...data,
|
||||
vendorId,
|
||||
vendor: selected?.name || '',
|
||||
});
|
||||
};
|
||||
const handleFilesSelect = (files: File[]) => {
|
||||
onChange({ ...data, attachments: [...data.attachments, ...files] });
|
||||
};
|
||||
|
||||
// 기존 업로드 파일 삭제
|
||||
const handleRemoveUploadedFile = (fileId: number) => {
|
||||
const updatedFiles = (data.uploadedFiles || []).filter((f) => f.id !== fileId);
|
||||
onChange({ ...data, uploadedFiles: updatedFiles });
|
||||
};
|
||||
|
||||
// 새 첨부 파일 삭제
|
||||
const handleRemoveAttachment = (index: number) => {
|
||||
const updatedAttachments = data.attachments.filter((_, i) => i !== index);
|
||||
onChange({ ...data, attachments: updatedAttachments });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 구매처 정보 */}
|
||||
<div className="bg-white rounded-lg border p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">구매처 정보</h3>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="vendor">구매처</Label>
|
||||
<Select
|
||||
value={data.vendorId || ''}
|
||||
onValueChange={handleVendorChange}
|
||||
disabled={isLoadingClients}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={isLoadingClients ? '불러오는 중...' : '구매처를 선택해주세요'} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{clients.map((client) => (
|
||||
<SelectItem key={client.id} value={client.id}>
|
||||
{client.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="vendorPaymentDate">구매처 결제일</Label>
|
||||
<DatePicker
|
||||
value={data.vendorPaymentDate}
|
||||
onChange={(date) => onChange({ ...data, vendorPaymentDate: date })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 품의서 정보 */}
|
||||
<div className="bg-white rounded-lg border p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">품의서 정보</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* 제목 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="title">제목</Label>
|
||||
<Input
|
||||
id="title"
|
||||
placeholder="제목을 입력해주세요"
|
||||
value={data.title}
|
||||
onChange={(e) => onChange({ ...data, title: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 품의 내역 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">품의 내역</Label>
|
||||
<div className="relative">
|
||||
<Textarea
|
||||
id="description"
|
||||
placeholder="품의 내역을 입력해주세요"
|
||||
value={data.description}
|
||||
onChange={(e) => onChange({ ...data, description: e.target.value })}
|
||||
className="min-h-[100px] pr-12"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="absolute right-2 bottom-2"
|
||||
title="녹음"
|
||||
>
|
||||
<Mic className="w-4 h-4" />
|
||||
녹음
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 품의 사유 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="reason">품의 사유</Label>
|
||||
<div className="relative">
|
||||
<Textarea
|
||||
id="reason"
|
||||
placeholder="품의 사유를 입력해주세요"
|
||||
value={data.reason}
|
||||
onChange={(e) => onChange({ ...data, reason: e.target.value })}
|
||||
className="min-h-[100px] pr-12"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="absolute right-2 bottom-2"
|
||||
title="녹음"
|
||||
>
|
||||
<Mic className="w-4 h-4" />
|
||||
녹음
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 예상 비용 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="estimatedCost">예상 비용</Label>
|
||||
<CurrencyInput
|
||||
id="estimatedCost"
|
||||
placeholder="금액을 입력해주세요"
|
||||
value={data.estimatedCost || 0}
|
||||
onChange={(value) => onChange({ ...data, estimatedCost: value ?? 0 })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 참고 이미지 정보 */}
|
||||
<div className="bg-white rounded-lg border p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">참고 이미지 정보</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>첨부파일</Label>
|
||||
<FileDropzone
|
||||
onFilesSelect={handleFilesSelect}
|
||||
multiple
|
||||
accept="image/*"
|
||||
maxSize={10}
|
||||
compact
|
||||
title="클릭하거나 파일을 드래그하세요"
|
||||
description="이미지 파일만 업로드 가능합니다"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 파일 목록 */}
|
||||
<FileList
|
||||
files={data.attachments.map((file): NewFile => ({ file }))}
|
||||
existingFiles={(data.uploadedFiles || []).map((file): ExistingFile => ({
|
||||
id: file.id,
|
||||
name: file.name,
|
||||
url: file.url,
|
||||
size: file.size,
|
||||
}))}
|
||||
onRemove={handleRemoveAttachment}
|
||||
onRemoveExisting={(id) => handleRemoveUploadedFile(id as number)}
|
||||
emptyMessage="첨부된 파일이 없습니다"
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Plus, Trash2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import type { ApprovalPerson } from './types';
|
||||
import { getEmployees } from './actions';
|
||||
|
||||
interface ReferenceSectionProps {
|
||||
data: ApprovalPerson[];
|
||||
onChange: (data: ApprovalPerson[]) => void;
|
||||
}
|
||||
|
||||
export function ReferenceSection({ data, onChange }: ReferenceSectionProps) {
|
||||
const [employees, setEmployees] = useState<ApprovalPerson[]>([]);
|
||||
|
||||
// 직원 목록 로드
|
||||
useEffect(() => {
|
||||
getEmployees().then(setEmployees);
|
||||
}, []);
|
||||
|
||||
const handleAdd = () => {
|
||||
const newPerson: ApprovalPerson = {
|
||||
id: `temp-${Date.now()}`,
|
||||
department: '',
|
||||
position: '',
|
||||
name: '',
|
||||
};
|
||||
onChange([...data, newPerson]);
|
||||
};
|
||||
|
||||
const handleRemove = (index: number) => {
|
||||
onChange(data.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const handleChange = (index: number, employeeId: string) => {
|
||||
const employee = employees.find((e) => e.id === employeeId);
|
||||
if (employee) {
|
||||
const newData = [...data];
|
||||
newData[index] = { ...employee };
|
||||
onChange(newData);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg border p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold">참조</h3>
|
||||
<Button variant="outline" size="sm" onClick={handleAdd}>
|
||||
<Plus className="w-4 h-4 mr-1" />
|
||||
추가
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="text-sm text-gray-500 mb-2">부서 / 직책 / 이름</div>
|
||||
|
||||
{data.length === 0 ? (
|
||||
<div className="text-center py-4 text-gray-400">
|
||||
참조자를 추가해주세요
|
||||
</div>
|
||||
) : (
|
||||
data.map((person, index) => (
|
||||
<div key={`${person.id}-${index}`} className="flex items-center gap-2">
|
||||
<span className="w-8 text-center text-sm text-gray-500">{index + 1}</span>
|
||||
<Select
|
||||
value={person.id.startsWith('temp-') ? undefined : person.id}
|
||||
onValueChange={(value) => handleChange(index, value)}
|
||||
>
|
||||
<SelectTrigger className="flex-1">
|
||||
<SelectValue
|
||||
placeholder={
|
||||
person.name && !person.id.startsWith('temp-')
|
||||
? `${person.department || ''} / ${person.position || ''} / ${person.name}`
|
||||
: "부서명 / 직책명 / 이름 ▼"
|
||||
}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{employees.map((employee) => (
|
||||
<SelectItem key={employee.id} value={employee.id}>
|
||||
{employee.department || ''} / {employee.position || ''} / {employee.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-9 w-9 text-red-500 hover:text-red-700 hover:bg-red-50"
|
||||
onClick={() => handleRemove(index)}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
719
src/components/approval_backup_v1/DocumentCreate/actions.ts
Normal file
719
src/components/approval_backup_v1/DocumentCreate/actions.ts
Normal file
@@ -0,0 +1,719 @@
|
||||
/**
|
||||
* 문서 작성 서버 액션
|
||||
*
|
||||
* API Endpoints:
|
||||
* - GET /api/v1/reports/expense-estimate - 비용견적서 항목 조회
|
||||
* - GET /api/v1/employees - 직원 목록 (결재선/참조 선택용)
|
||||
* - POST /api/v1/approvals - 결재 문서 생성 (임시저장)
|
||||
* - POST /api/v1/approvals/{id}/submit - 결재 문서 상신
|
||||
*/
|
||||
|
||||
'use server';
|
||||
|
||||
|
||||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||
import { cookies } from 'next/headers';
|
||||
import { executeServerAction } from '@/lib/api/execute-server-action';
|
||||
import { buildApiUrl } from '@/lib/api/query-params';
|
||||
import type {
|
||||
ExpenseEstimateItem,
|
||||
ApprovalPerson,
|
||||
DocumentFormData,
|
||||
UploadedFile,
|
||||
} from './types';
|
||||
|
||||
// ============================================
|
||||
// API 응답 타입 정의
|
||||
// ============================================
|
||||
|
||||
// 비용견적서 API 응답 타입
|
||||
interface ExpenseEstimateApiItem {
|
||||
id: number;
|
||||
expected_payment_date: string;
|
||||
category: string;
|
||||
amount: number;
|
||||
vendor: string;
|
||||
account_info?: string;
|
||||
memo?: string;
|
||||
}
|
||||
|
||||
interface ExpenseEstimateApiResponse {
|
||||
year_month: string;
|
||||
items: ExpenseEstimateApiItem[];
|
||||
total_expense: number;
|
||||
account_balance: number;
|
||||
final_difference: number;
|
||||
}
|
||||
|
||||
// 직원 API 응답 타입 (TenantUserProfile 구조)
|
||||
interface EmployeeApiData {
|
||||
id: number; // TenantUserProfile.id
|
||||
user_id: number; // User.id (결재선에 사용)
|
||||
position_key?: string; // 직책 코드 (EXECUTIVE, DIRECTOR 등)
|
||||
user?: {
|
||||
id: number;
|
||||
name: string;
|
||||
email?: string;
|
||||
};
|
||||
department?: {
|
||||
id: number;
|
||||
name: string;
|
||||
};
|
||||
}
|
||||
|
||||
// 결재 문서 생성 응답 타입
|
||||
interface ApprovalCreateResponse {
|
||||
id: number;
|
||||
document_number: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 헬퍼 함수
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* 비용견적서 API 데이터 → 프론트엔드 데이터 변환
|
||||
*/
|
||||
function transformExpenseEstimateItem(item: ExpenseEstimateApiItem): ExpenseEstimateItem {
|
||||
return {
|
||||
id: String(item.id),
|
||||
checked: false,
|
||||
expectedPaymentDate: item.expected_payment_date,
|
||||
category: item.category,
|
||||
amount: item.amount,
|
||||
vendor: item.vendor,
|
||||
memo: item.account_info || item.memo || '',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 직책 코드를 한글 라벨로 변환 (직원 목록용)
|
||||
*/
|
||||
function getPositionLabelForEmployee(positionKey: string | null | undefined): string {
|
||||
if (!positionKey) return '';
|
||||
const labels: Record<string, string> = {
|
||||
'EXECUTIVE': '임원',
|
||||
'DIRECTOR': '부장',
|
||||
'MANAGER': '과장',
|
||||
'SENIOR': '대리',
|
||||
'STAFF': '사원',
|
||||
'INTERN': '인턴',
|
||||
};
|
||||
return labels[positionKey] ?? positionKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* 직원 API 데이터 → 결재자 데이터 변환
|
||||
* API는 TenantUserProfile 구조를 반환함
|
||||
*/
|
||||
function transformEmployee(employee: EmployeeApiData): ApprovalPerson {
|
||||
return {
|
||||
id: String(employee.user?.id || employee.user_id), // User.id 사용 (결재선에 필요)
|
||||
name: employee.user?.name || '',
|
||||
position: getPositionLabelForEmployee(employee.position_key),
|
||||
department: employee.department?.name || '',
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// API 함수
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* 파일 업로드
|
||||
* @param files 업로드할 파일 배열
|
||||
* @returns 업로드된 파일 정보 배열
|
||||
*
|
||||
* NOTE: 파일 업로드는 multipart/form-data가 필요하므로 serverFetch 대신 직접 fetch 사용
|
||||
*/
|
||||
export async function uploadFiles(files: File[]): Promise<{
|
||||
success: boolean;
|
||||
data?: UploadedFile[];
|
||||
error?: string;
|
||||
}> {
|
||||
if (files.length === 0) {
|
||||
return { success: true, data: [] };
|
||||
}
|
||||
|
||||
try {
|
||||
const cookieStore = await cookies();
|
||||
const token = cookieStore.get('access_token')?.value;
|
||||
|
||||
const uploadedFiles: UploadedFile[] = [];
|
||||
|
||||
// 파일을 하나씩 업로드 (멀티파트 폼)
|
||||
for (const file of files) {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
const response = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/files/upload`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': token ? `Bearer ${token}` : '',
|
||||
'X-API-KEY': process.env.API_KEY || '',
|
||||
// Content-Type은 자동 설정됨 (multipart/form-data)
|
||||
},
|
||||
body: formData,
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('[DocumentCreateActions] File upload error:', response.status);
|
||||
return { success: false, error: `파일 업로드 실패: ${file.name}` };
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
if (result.success && result.data) {
|
||||
// API 응답 필드: id, display_name, file_path, file_size, mime_type
|
||||
uploadedFiles.push({
|
||||
id: result.data.id,
|
||||
name: result.data.display_name || file.name,
|
||||
url: `/api/proxy/files/${result.data.id}/download`,
|
||||
size: result.data.file_size,
|
||||
mime_type: result.data.mime_type,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true, data: uploadedFiles };
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('[DocumentCreateActions] uploadFiles error:', error);
|
||||
return { success: false, error: '파일 업로드 중 오류가 발생했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 비용견적서 항목 조회
|
||||
*/
|
||||
export async function getExpenseEstimateItems(yearMonth?: string): Promise<{
|
||||
items: ExpenseEstimateItem[];
|
||||
totalExpense: number;
|
||||
accountBalance: number;
|
||||
finalDifference: number;
|
||||
} | null> {
|
||||
const result = await executeServerAction<ExpenseEstimateApiResponse>({
|
||||
url: buildApiUrl('/api/v1/reports/expense-estimate', { year_month: yearMonth }),
|
||||
errorMessage: '비용견적서 조회에 실패했습니다.',
|
||||
});
|
||||
if (!result.success || !result.data) return null;
|
||||
return {
|
||||
items: result.data.items.map(transformExpenseEstimateItem),
|
||||
totalExpense: result.data.total_expense,
|
||||
accountBalance: result.data.account_balance,
|
||||
finalDifference: result.data.final_difference,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 직원 목록 조회 (결재선/참조 선택용)
|
||||
*/
|
||||
export async function getEmployees(search?: string): Promise<ApprovalPerson[]> {
|
||||
const result = await executeServerAction<{ data: EmployeeApiData[] }>({
|
||||
url: buildApiUrl('/api/v1/employees', { per_page: 100, search }),
|
||||
errorMessage: '직원 목록 조회에 실패했습니다.',
|
||||
});
|
||||
if (!result.success || !result.data?.data) return [];
|
||||
return result.data.data.map(transformEmployee);
|
||||
}
|
||||
|
||||
/**
|
||||
* 결재 문서 생성 (임시저장)
|
||||
*/
|
||||
export async function createApproval(formData: DocumentFormData): Promise<{
|
||||
success: boolean;
|
||||
data?: { id: number; documentNo: string };
|
||||
error?: string;
|
||||
}> {
|
||||
// 새 첨부파일 업로드
|
||||
const newFiles = formData.proposalData?.attachments
|
||||
|| formData.expenseReportData?.attachments
|
||||
|| [];
|
||||
let uploadedFiles: UploadedFile[] = [];
|
||||
|
||||
if (newFiles.length > 0) {
|
||||
const uploadResult = await uploadFiles(newFiles);
|
||||
if (!uploadResult.success) {
|
||||
return { success: false, error: uploadResult.error };
|
||||
}
|
||||
uploadedFiles = uploadResult.data || [];
|
||||
}
|
||||
|
||||
const requestBody = {
|
||||
form_code: formData.basicInfo.documentType,
|
||||
title: getDocumentTitle(formData),
|
||||
status: 'draft',
|
||||
steps: [
|
||||
...formData.approvalLine.map((person, index) => ({
|
||||
step_type: 'approval',
|
||||
step_order: index + 1,
|
||||
approver_id: parseInt(person.id),
|
||||
})),
|
||||
...formData.references.map((person, index) => ({
|
||||
step_type: 'reference',
|
||||
step_order: formData.approvalLine.length + index + 1,
|
||||
approver_id: parseInt(person.id),
|
||||
})),
|
||||
],
|
||||
content: getDocumentContent(formData, uploadedFiles),
|
||||
};
|
||||
|
||||
const result = await executeServerAction<ApprovalCreateResponse>({
|
||||
url: buildApiUrl('/api/v1/approvals'),
|
||||
method: 'POST',
|
||||
body: requestBody,
|
||||
errorMessage: '문서 저장에 실패했습니다.',
|
||||
});
|
||||
if (!result.success || !result.data) return { success: false, error: result.error };
|
||||
return { success: true, data: { id: result.data.id, documentNo: result.data.document_number } };
|
||||
}
|
||||
|
||||
/**
|
||||
* 결재 문서 상신
|
||||
*/
|
||||
export async function submitApproval(id: number): Promise<{
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}> {
|
||||
const result = await executeServerAction({
|
||||
url: buildApiUrl(`/api/v1/approvals/${id}/submit`),
|
||||
method: 'POST',
|
||||
body: {},
|
||||
errorMessage: '문서 상신에 실패했습니다.',
|
||||
});
|
||||
return { success: result.success, error: result.error };
|
||||
}
|
||||
|
||||
/**
|
||||
* 결재 문서 생성 및 상신 (한번에)
|
||||
*/
|
||||
export async function createAndSubmitApproval(formData: DocumentFormData): Promise<{
|
||||
success: boolean;
|
||||
data?: { id: number; documentNo: string };
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
// 1. 먼저 문서 생성
|
||||
const createResult = await createApproval(formData);
|
||||
if (!createResult.success || !createResult.data) {
|
||||
return createResult;
|
||||
}
|
||||
|
||||
// 2. 상신
|
||||
const submitResult = await submitApproval(createResult.data.id);
|
||||
if (!submitResult.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: submitResult.error || '문서 상신에 실패했습니다.',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: createResult.data,
|
||||
};
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('[DocumentCreateActions] createAndSubmitApproval error:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: '서버 오류가 발생했습니다.',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 결재 문서 조회 (수정 모드용)
|
||||
*/
|
||||
export async function getApprovalById(id: number): Promise<{
|
||||
success: boolean;
|
||||
data?: DocumentFormData;
|
||||
error?: string;
|
||||
}> {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const result = await executeServerAction<any>({
|
||||
url: buildApiUrl(`/api/v1/approvals/${id}`),
|
||||
errorMessage: '문서 조회에 실패했습니다.',
|
||||
});
|
||||
if (!result.success || !result.data) return { success: false, error: result.error };
|
||||
return { success: true, data: transformApiToFormData(result.data) };
|
||||
}
|
||||
|
||||
/**
|
||||
* 결재 문서 수정
|
||||
*/
|
||||
export async function updateApproval(id: number, formData: DocumentFormData): Promise<{
|
||||
success: boolean;
|
||||
data?: { id: number; documentNo: string };
|
||||
error?: string;
|
||||
}> {
|
||||
// 새 첨부파일 업로드
|
||||
const newFiles = formData.proposalData?.attachments
|
||||
|| formData.expenseReportData?.attachments
|
||||
|| [];
|
||||
let uploadedFiles: UploadedFile[] = [];
|
||||
|
||||
if (newFiles.length > 0) {
|
||||
const uploadResult = await uploadFiles(newFiles);
|
||||
if (!uploadResult.success) {
|
||||
return { success: false, error: uploadResult.error };
|
||||
}
|
||||
uploadedFiles = uploadResult.data || [];
|
||||
}
|
||||
|
||||
const requestBody = {
|
||||
form_code: formData.basicInfo.documentType,
|
||||
title: getDocumentTitle(formData),
|
||||
steps: [
|
||||
...formData.approvalLine.map((person, index) => ({
|
||||
step_type: 'approval',
|
||||
step_order: index + 1,
|
||||
approver_id: parseInt(person.id),
|
||||
})),
|
||||
...formData.references.map((person, index) => ({
|
||||
step_type: 'reference',
|
||||
step_order: formData.approvalLine.length + index + 1,
|
||||
approver_id: parseInt(person.id),
|
||||
})),
|
||||
],
|
||||
content: getDocumentContent(formData, uploadedFiles),
|
||||
};
|
||||
|
||||
const result = await executeServerAction<ApprovalCreateResponse>({
|
||||
url: buildApiUrl(`/api/v1/approvals/${id}`),
|
||||
method: 'PATCH',
|
||||
body: requestBody,
|
||||
errorMessage: '문서 수정에 실패했습니다.',
|
||||
});
|
||||
if (!result.success || !result.data) return { success: false, error: result.error };
|
||||
return { success: true, data: { id: result.data.id, documentNo: result.data.document_number } };
|
||||
}
|
||||
|
||||
/**
|
||||
* 결재 문서 수정 및 상신
|
||||
*/
|
||||
export async function updateAndSubmitApproval(id: number, formData: DocumentFormData): Promise<{
|
||||
success: boolean;
|
||||
data?: { id: number; documentNo: string };
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
// 1. 먼저 문서 수정
|
||||
const updateResult = await updateApproval(id, formData);
|
||||
if (!updateResult.success) {
|
||||
return updateResult;
|
||||
}
|
||||
|
||||
// 2. 상신
|
||||
const submitResult = await submitApproval(id);
|
||||
if (!submitResult.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: submitResult.error || '문서 상신에 실패했습니다.',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: updateResult.data,
|
||||
};
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('[DocumentCreateActions] updateAndSubmitApproval error:', error);
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 결재 문서 삭제
|
||||
*/
|
||||
export async function deleteApproval(id: number): Promise<{
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}> {
|
||||
const result = await executeServerAction({
|
||||
url: buildApiUrl(`/api/v1/approvals/${id}`),
|
||||
method: 'DELETE',
|
||||
errorMessage: '문서 삭제에 실패했습니다.',
|
||||
});
|
||||
return { success: result.success, error: result.error };
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 내부 헬퍼 함수
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* 문서 제목 생성
|
||||
*/
|
||||
function getDocumentTitle(formData: DocumentFormData): string {
|
||||
switch (formData.basicInfo.documentType) {
|
||||
case 'proposal':
|
||||
return formData.proposalData?.title || '품의서';
|
||||
case 'expenseReport':
|
||||
return `지출결의서 - ${formData.expenseReportData?.requestDate || ''}`;
|
||||
case 'expenseEstimate':
|
||||
return `지출 예상 내역서`;
|
||||
default:
|
||||
return '문서';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 직책 코드를 한글 라벨로 변환
|
||||
*/
|
||||
function getPositionLabel(positionKey: string | null | undefined): string {
|
||||
if (!positionKey) return '';
|
||||
const labels: Record<string, string> = {
|
||||
'EXECUTIVE': '임원',
|
||||
'DIRECTOR': '부장',
|
||||
'MANAGER': '과장',
|
||||
'SENIOR': '대리',
|
||||
'STAFF': '사원',
|
||||
'INTERN': '인턴',
|
||||
};
|
||||
return labels[positionKey] ?? positionKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* API 응답을 프론트엔드 폼 데이터로 변환
|
||||
*/
|
||||
function transformApiToFormData(apiData: {
|
||||
id: number;
|
||||
document_number: string;
|
||||
form_code?: string; // 이전 호환성
|
||||
form?: { // 현재 API 구조
|
||||
id: number;
|
||||
name: string;
|
||||
code: string;
|
||||
category?: string;
|
||||
template?: Record<string, unknown>;
|
||||
};
|
||||
title: string;
|
||||
status: string;
|
||||
content: Record<string, unknown>;
|
||||
steps?: Array<{
|
||||
step_type: string;
|
||||
approver_id: number;
|
||||
approver?: {
|
||||
id: number;
|
||||
name: string;
|
||||
position?: string;
|
||||
department?: { name: string };
|
||||
tenant_profile?: {
|
||||
position_key?: string;
|
||||
display_name?: string;
|
||||
department?: { name: string };
|
||||
};
|
||||
};
|
||||
}>;
|
||||
created_at: string;
|
||||
requester?: {
|
||||
name: string;
|
||||
};
|
||||
drafter?: {
|
||||
id: number;
|
||||
name: string;
|
||||
tenant_profile?: {
|
||||
position_key?: string;
|
||||
display_name?: string;
|
||||
department?: { name: string };
|
||||
};
|
||||
};
|
||||
}): DocumentFormData {
|
||||
// form.code를 우선 사용, 없으면 form_code (이전 호환성)
|
||||
const formCode = apiData.form?.code || apiData.form_code || 'proposal';
|
||||
const documentType = formCode as 'proposal' | 'expenseReport' | 'expenseEstimate';
|
||||
const content = apiData.content || {};
|
||||
|
||||
// 결재선 및 참조자 분리
|
||||
const approvalLine: ApprovalPerson[] = [];
|
||||
const references: ApprovalPerson[] = [];
|
||||
|
||||
if (apiData.steps) {
|
||||
for (const step of apiData.steps) {
|
||||
if (step.approver) {
|
||||
// tenantProfile에서 직책/부서 정보 추출 (우선), 없으면 기존 필드 사용
|
||||
const tenantProfile = step.approver.tenant_profile;
|
||||
const position = tenantProfile?.position_key
|
||||
? getPositionLabel(tenantProfile.position_key)
|
||||
: (step.approver.position || '');
|
||||
const department = tenantProfile?.department?.name
|
||||
|| step.approver.department?.name
|
||||
|| '';
|
||||
|
||||
const person: ApprovalPerson = {
|
||||
id: String(step.approver.id),
|
||||
name: step.approver.name,
|
||||
position,
|
||||
department,
|
||||
};
|
||||
|
||||
// 'approval'과 'agreement' 모두 결재선에 포함
|
||||
if (step.step_type === 'approval' || step.step_type === 'agreement') {
|
||||
approvalLine.push(person);
|
||||
} else if (step.step_type === 'reference') {
|
||||
references.push(person);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 기본 정보 (drafter에서 tenantProfile 정보 추출)
|
||||
const drafterProfile = apiData.drafter?.tenant_profile;
|
||||
const basicInfo = {
|
||||
drafter: apiData.drafter?.name || apiData.requester?.name || '',
|
||||
drafterPosition: drafterProfile?.position_key
|
||||
? getPositionLabel(drafterProfile.position_key)
|
||||
: '',
|
||||
drafterDepartment: drafterProfile?.department?.name || '',
|
||||
draftDate: apiData.created_at,
|
||||
documentNo: apiData.document_number,
|
||||
documentType,
|
||||
};
|
||||
|
||||
// 기존 업로드 파일 추출
|
||||
const existingFiles = (content.files as Array<{
|
||||
id: number;
|
||||
name: string;
|
||||
url?: string;
|
||||
size?: number;
|
||||
mime_type?: string;
|
||||
}>) || [];
|
||||
const uploadedFiles: UploadedFile[] = existingFiles.map(f => ({
|
||||
id: f.id,
|
||||
name: f.name,
|
||||
// URL이 없거나 상대 경로인 경우 다운로드 URL 생성
|
||||
url: f.url?.startsWith('http')
|
||||
? f.url
|
||||
: `/api/proxy/files/${f.id}/download`,
|
||||
size: f.size,
|
||||
mime_type: f.mime_type,
|
||||
}));
|
||||
|
||||
// 문서 유형별 데이터 변환
|
||||
let proposalData;
|
||||
let expenseReportData;
|
||||
let expenseEstimateData;
|
||||
|
||||
if (documentType === 'proposal') {
|
||||
proposalData = {
|
||||
vendorId: (content.vendorId as string) || '',
|
||||
vendor: (content.vendor as string) || '',
|
||||
vendorPaymentDate: (content.vendorPaymentDate as string) || '',
|
||||
title: (content.title as string) || '',
|
||||
description: (content.description as string) || '',
|
||||
reason: (content.reason as string) || '',
|
||||
estimatedCost: (content.estimatedCost as number) || 0,
|
||||
attachments: [],
|
||||
uploadedFiles,
|
||||
};
|
||||
} else if (documentType === 'expenseReport') {
|
||||
const items = (content.items as Array<{
|
||||
id: string;
|
||||
description: string;
|
||||
amount: number;
|
||||
note?: string;
|
||||
}>) || [];
|
||||
|
||||
expenseReportData = {
|
||||
requestDate: (content.requestDate as string) || '',
|
||||
paymentDate: (content.paymentDate as string) || '',
|
||||
items: items.map(item => ({
|
||||
id: item.id,
|
||||
description: item.description,
|
||||
amount: item.amount,
|
||||
note: item.note || '',
|
||||
})),
|
||||
cardId: (content.cardId as string) || '',
|
||||
totalAmount: (content.totalAmount as number) || 0,
|
||||
attachments: [],
|
||||
uploadedFiles,
|
||||
};
|
||||
} else if (documentType === 'expenseEstimate') {
|
||||
const items = (content.items as Array<{
|
||||
id: string;
|
||||
checked: boolean;
|
||||
expectedPaymentDate: string;
|
||||
category: string;
|
||||
amount: number;
|
||||
vendor: string;
|
||||
memo?: string;
|
||||
}>) || [];
|
||||
|
||||
expenseEstimateData = {
|
||||
items: items.map(item => ({
|
||||
id: item.id,
|
||||
checked: item.checked || false,
|
||||
expectedPaymentDate: item.expectedPaymentDate,
|
||||
category: item.category,
|
||||
amount: item.amount,
|
||||
vendor: item.vendor,
|
||||
memo: item.memo || '',
|
||||
})),
|
||||
totalExpense: (content.totalExpense as number) || 0,
|
||||
accountBalance: (content.accountBalance as number) || 0,
|
||||
finalDifference: (content.finalDifference as number) || 0,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
basicInfo,
|
||||
approvalLine,
|
||||
references,
|
||||
proposalData,
|
||||
expenseReportData,
|
||||
expenseEstimateData,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 문서 내용 생성 (JSON)
|
||||
* @param formData 폼 데이터
|
||||
* @param uploadedFiles 새로 업로드된 파일 목록
|
||||
*/
|
||||
function getDocumentContent(
|
||||
formData: DocumentFormData,
|
||||
uploadedFiles: UploadedFile[] = []
|
||||
): Record<string, unknown> {
|
||||
// 기존 업로드 파일 + 새로 업로드된 파일 합치기
|
||||
const existingFiles = formData.proposalData?.uploadedFiles
|
||||
|| formData.expenseReportData?.uploadedFiles
|
||||
|| [];
|
||||
const allFiles = [...existingFiles, ...uploadedFiles];
|
||||
|
||||
switch (formData.basicInfo.documentType) {
|
||||
case 'proposal':
|
||||
return {
|
||||
vendorId: formData.proposalData?.vendorId,
|
||||
vendor: formData.proposalData?.vendor,
|
||||
vendorPaymentDate: formData.proposalData?.vendorPaymentDate,
|
||||
title: formData.proposalData?.title,
|
||||
description: formData.proposalData?.description,
|
||||
reason: formData.proposalData?.reason,
|
||||
estimatedCost: formData.proposalData?.estimatedCost,
|
||||
files: allFiles,
|
||||
};
|
||||
case 'expenseReport':
|
||||
return {
|
||||
requestDate: formData.expenseReportData?.requestDate,
|
||||
paymentDate: formData.expenseReportData?.paymentDate,
|
||||
items: formData.expenseReportData?.items,
|
||||
cardId: formData.expenseReportData?.cardId,
|
||||
totalAmount: formData.expenseReportData?.totalAmount,
|
||||
files: allFiles,
|
||||
};
|
||||
case 'expenseEstimate':
|
||||
return {
|
||||
items: formData.expenseEstimateData?.items,
|
||||
totalExpense: formData.expenseEstimateData?.totalExpense,
|
||||
accountBalance: formData.expenseEstimateData?.accountBalance,
|
||||
finalDifference: formData.expenseEstimateData?.finalDifference,
|
||||
};
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* 기안 문서 작성/수정 페이지 설정
|
||||
* IntegratedDetailTemplate 마이그레이션 (2025-01-20)
|
||||
*/
|
||||
|
||||
import { FileText } from 'lucide-react';
|
||||
import type { DetailConfig } from '@/components/templates/IntegratedDetailTemplate/types';
|
||||
|
||||
export const documentCreateConfig: DetailConfig = {
|
||||
title: '문서 작성',
|
||||
description: '새로운 결재 문서를 작성합니다',
|
||||
icon: FileText,
|
||||
basePath: '/approval/draft',
|
||||
fields: [],
|
||||
actions: {
|
||||
showBack: true,
|
||||
showEdit: false,
|
||||
showDelete: false, // 커스텀 삭제 버튼 사용
|
||||
showSave: false, // 상신/임시저장 버튼 사용
|
||||
},
|
||||
};
|
||||
|
||||
export const documentEditConfig: DetailConfig = {
|
||||
...documentCreateConfig,
|
||||
title: '문서',
|
||||
description: '기존 결재 문서를 수정합니다',
|
||||
// actions는 documentCreateConfig에서 상속 (커스텀 버튼 사용)
|
||||
};
|
||||
|
||||
export const documentCopyConfig: DetailConfig = {
|
||||
...documentCreateConfig,
|
||||
title: '문서',
|
||||
description: '복제된 문서를 수정 후 상신합니다',
|
||||
};
|
||||
631
src/components/approval_backup_v1/DocumentCreate/index.tsx
Normal file
631
src/components/approval_backup_v1/DocumentCreate/index.tsx
Normal file
@@ -0,0 +1,631 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback, useEffect, useTransition, useRef, useMemo } from 'react';
|
||||
import { invalidateDashboard } from '@/lib/dashboard-invalidation';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { usePermission } from '@/hooks/usePermission';
|
||||
import { format } from 'date-fns';
|
||||
import { Trash2, Send, Save, Eye } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { DeleteConfirmDialog } from '@/components/ui/confirm-dialog';
|
||||
import { IntegratedDetailTemplate, type ActionItem } from '@/components/templates/IntegratedDetailTemplate';
|
||||
import {
|
||||
documentCreateConfig,
|
||||
documentEditConfig,
|
||||
documentCopyConfig,
|
||||
} from './documentCreateConfig';
|
||||
import {
|
||||
getExpenseEstimateItems,
|
||||
createApproval,
|
||||
createAndSubmitApproval,
|
||||
getApprovalById,
|
||||
updateApproval,
|
||||
updateAndSubmitApproval,
|
||||
deleteApproval,
|
||||
getEmployees,
|
||||
} from './actions';
|
||||
import { useAuthStore } from '@/stores/authStore';
|
||||
import { BasicInfoSection } from './BasicInfoSection';
|
||||
import { ApprovalLineSection } from './ApprovalLineSection';
|
||||
import { ReferenceSection } from './ReferenceSection';
|
||||
import { ProposalForm } from './ProposalForm';
|
||||
import { ExpenseReportForm } from './ExpenseReportForm';
|
||||
import { ExpenseEstimateForm } from './ExpenseEstimateForm';
|
||||
import { DocumentDetailModal } from '@/components/approval/DocumentDetail';
|
||||
import type {
|
||||
DocumentType as ModalDocumentType,
|
||||
ProposalDocumentData,
|
||||
ExpenseReportDocumentData,
|
||||
ExpenseEstimateDocumentData,
|
||||
} from '@/components/approval/DocumentDetail/types';
|
||||
import type {
|
||||
BasicInfo,
|
||||
ApprovalPerson,
|
||||
ProposalData,
|
||||
ExpenseReportData,
|
||||
ExpenseEstimateData,
|
||||
} from './types';
|
||||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||
import { useDevFill, generatePurchaseApprovalData } from '@/components/dev';
|
||||
import { getClients } from '@/components/accounting/VendorManagement/actions';
|
||||
|
||||
// 초기 데이터 - SSR에서는 빈 문자열, 클라이언트에서 날짜 설정
|
||||
const getInitialBasicInfo = (): BasicInfo => ({
|
||||
drafter: '', // 클라이언트에서 currentUser로 설정
|
||||
draftDate: '', // 클라이언트에서 설정
|
||||
documentNo: '',
|
||||
documentType: 'proposal',
|
||||
});
|
||||
|
||||
const getInitialProposalData = (): ProposalData => ({
|
||||
vendorId: '',
|
||||
vendor: '',
|
||||
vendorPaymentDate: '', // 클라이언트에서 설정
|
||||
title: '',
|
||||
description: '',
|
||||
reason: '',
|
||||
estimatedCost: 0,
|
||||
attachments: [],
|
||||
});
|
||||
|
||||
const getInitialExpenseReportData = (): ExpenseReportData => ({
|
||||
requestDate: '', // 클라이언트에서 설정
|
||||
paymentDate: '', // 클라이언트에서 설정
|
||||
items: [],
|
||||
cardId: '',
|
||||
totalAmount: 0,
|
||||
attachments: [],
|
||||
});
|
||||
|
||||
const getInitialExpenseEstimateData = (): ExpenseEstimateData => ({
|
||||
items: [],
|
||||
totalExpense: 0,
|
||||
accountBalance: 10000000,
|
||||
finalDifference: 10000000,
|
||||
});
|
||||
|
||||
export function DocumentCreate() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const currentUser = useAuthStore((state) => state.currentUser);
|
||||
const { canCreate, canDelete } = usePermission();
|
||||
|
||||
// 수정 모드 / 복제 모드 상태
|
||||
const documentId = searchParams.get('id');
|
||||
const mode = searchParams.get('mode');
|
||||
const copyFromId = searchParams.get('copyFrom');
|
||||
const isEditMode = mode === 'edit' && !!documentId;
|
||||
const isCopyMode = !!copyFromId;
|
||||
const [isLoadingDocument, setIsLoadingDocument] = useState(false);
|
||||
|
||||
// 상태 관리
|
||||
const [basicInfo, setBasicInfo] = useState<BasicInfo>(getInitialBasicInfo);
|
||||
const [approvalLine, setApprovalLine] = useState<ApprovalPerson[]>([]);
|
||||
const [references, setReferences] = useState<ApprovalPerson[]>([]);
|
||||
const [proposalData, setProposalData] = useState<ProposalData>(getInitialProposalData);
|
||||
const [expenseReportData, setExpenseReportData] = useState<ExpenseReportData>(getInitialExpenseReportData);
|
||||
const [expenseEstimateData, setExpenseEstimateData] = useState<ExpenseEstimateData>(getInitialExpenseEstimateData);
|
||||
const [isLoadingEstimate, setIsLoadingEstimate] = useState(false);
|
||||
|
||||
// 복제 모드 toast 중복 호출 방지
|
||||
const copyToastShownRef = useRef(false);
|
||||
|
||||
// 삭제 확인 다이얼로그
|
||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||
|
||||
// Hydration 불일치 방지: 클라이언트에서만 날짜 초기화
|
||||
useEffect(() => {
|
||||
const today = format(new Date(), 'yyyy-MM-dd');
|
||||
const now = format(new Date(), 'yyyy-MM-dd HH:mm');
|
||||
|
||||
// localStorage 'user' 키에서 사용자 이름 가져오기 (로그인 시 저장됨)
|
||||
const userDataStr = typeof window !== 'undefined' ? localStorage.getItem('user') : null;
|
||||
const userName = userDataStr ? JSON.parse(userDataStr).name : currentUser?.name || '';
|
||||
|
||||
setBasicInfo(prev => ({
|
||||
...prev,
|
||||
drafter: prev.drafter || userName,
|
||||
draftDate: prev.draftDate || now,
|
||||
}));
|
||||
setProposalData(prev => ({ ...prev, vendorPaymentDate: prev.vendorPaymentDate || today }));
|
||||
setExpenseReportData(prev => ({
|
||||
...prev,
|
||||
requestDate: prev.requestDate || today,
|
||||
paymentDate: prev.paymentDate || today,
|
||||
}));
|
||||
}, [currentUser?.name]);
|
||||
|
||||
// 미리보기 모달 상태
|
||||
const [isPreviewOpen, setIsPreviewOpen] = useState(false);
|
||||
|
||||
// ===== DevFill: 자동 입력 기능 =====
|
||||
useDevFill('purchaseApproval', useCallback(async () => {
|
||||
if (!isEditMode && !isCopyMode) {
|
||||
const mockData = generatePurchaseApprovalData();
|
||||
|
||||
// 직원 목록 가져오기
|
||||
const employees = await getEmployees();
|
||||
|
||||
// 거래처 목록 가져오기 (매입 거래처만)
|
||||
const clientsResult = await getClients({ size: 1000, only_active: true });
|
||||
const purchaseClients = clientsResult.success
|
||||
? clientsResult.data
|
||||
.filter((v) => v.category === 'purchase' || v.category === 'both')
|
||||
.map((v) => ({ id: v.id, name: v.vendorName }))
|
||||
: [];
|
||||
|
||||
// 랜덤 거래처 선택
|
||||
const randomClient = purchaseClients.length > 0
|
||||
? purchaseClients[Math.floor(Math.random() * purchaseClients.length)]
|
||||
: null;
|
||||
|
||||
// localStorage에서 실제 로그인 사용자 이름 가져오기 (우측 상단 표시와 동일한 소스)
|
||||
const userDataStr = localStorage.getItem("user");
|
||||
const currentUserName = userDataStr ? JSON.parse(userDataStr).name : currentUser?.name;
|
||||
|
||||
// 현재 사용자 이름으로 결재선에 추가할 직원 찾기
|
||||
const approver = currentUserName
|
||||
? employees.find(e => e.name === currentUserName)
|
||||
: null;
|
||||
|
||||
// 경리/회계/재무 부서 직원 중 랜덤 1명 참조 추가
|
||||
const accountingDepts = ['경리', '회계', '재무'];
|
||||
const accountingStaff = employees.filter(e =>
|
||||
accountingDepts.some(dept => e.department?.includes(dept))
|
||||
);
|
||||
const randomReference = accountingStaff.length > 0
|
||||
? accountingStaff[Math.floor(Math.random() * accountingStaff.length)]
|
||||
: null;
|
||||
|
||||
setBasicInfo(prev => ({
|
||||
...prev,
|
||||
...mockData.basicInfo,
|
||||
drafter: currentUserName || prev.drafter,
|
||||
draftDate: prev.draftDate || mockData.basicInfo.draftDate,
|
||||
documentType: (mockData.basicInfo.documentType || prev.documentType) as BasicInfo['documentType'],
|
||||
}));
|
||||
|
||||
// 결재선: 현재 사용자가 직원 목록에 있으면 설정, 없으면 랜덤 1명
|
||||
if (approver) {
|
||||
setApprovalLine([approver]);
|
||||
} else if (employees.length > 0) {
|
||||
const randomApprover = employees[Math.floor(Math.random() * employees.length)];
|
||||
setApprovalLine([randomApprover]);
|
||||
}
|
||||
|
||||
// 참조: 경리/회계/재무 직원이 있으면 설정
|
||||
if (randomReference) {
|
||||
setReferences([randomReference]);
|
||||
}
|
||||
|
||||
setProposalData(prev => ({
|
||||
...prev,
|
||||
...mockData.proposalData,
|
||||
// 실제 API 거래처로 덮어쓰기
|
||||
vendorId: randomClient?.id || '',
|
||||
vendor: randomClient?.name || '',
|
||||
}));
|
||||
toast.success('지출결의서 데이터가 자동 입력되었습니다.');
|
||||
}
|
||||
}, [isEditMode, isCopyMode, currentUser?.name]));
|
||||
|
||||
// 수정 모드: 문서 로드
|
||||
useEffect(() => {
|
||||
if (!isEditMode || !documentId) return;
|
||||
|
||||
const loadDocument = async () => {
|
||||
setIsLoadingDocument(true);
|
||||
try {
|
||||
const result = await getApprovalById(parseInt(documentId));
|
||||
if (result.success && result.data) {
|
||||
const { basicInfo: loadedBasicInfo, approvalLine: loadedApprovalLine, references: loadedReferences, proposalData: loadedProposalData, expenseReportData: loadedExpenseReportData, expenseEstimateData: loadedExpenseEstimateData } = result.data;
|
||||
|
||||
setBasicInfo(loadedBasicInfo);
|
||||
setApprovalLine(loadedApprovalLine);
|
||||
setReferences(loadedReferences);
|
||||
if (loadedProposalData) setProposalData(loadedProposalData);
|
||||
if (loadedExpenseReportData) setExpenseReportData(loadedExpenseReportData);
|
||||
if (loadedExpenseEstimateData) setExpenseEstimateData(loadedExpenseEstimateData);
|
||||
} else {
|
||||
toast.error(result.error || '문서를 불러오는데 실패했습니다.');
|
||||
router.back();
|
||||
}
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('Failed to load document:', error);
|
||||
toast.error('문서를 불러오는데 실패했습니다.');
|
||||
router.back();
|
||||
} finally {
|
||||
setIsLoadingDocument(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadDocument();
|
||||
}, [isEditMode, documentId, router]);
|
||||
|
||||
// 복제 모드: 원본 문서 로드 후 새 문서로 설정
|
||||
useEffect(() => {
|
||||
if (!isCopyMode || !copyFromId) return;
|
||||
|
||||
const loadDocumentForCopy = async () => {
|
||||
setIsLoadingDocument(true);
|
||||
try {
|
||||
const result = await getApprovalById(parseInt(copyFromId));
|
||||
if (result.success && result.data) {
|
||||
const { basicInfo: loadedBasicInfo, approvalLine: loadedApprovalLine, references: loadedReferences, proposalData: loadedProposalData, expenseReportData: loadedExpenseReportData, expenseEstimateData: loadedExpenseEstimateData } = result.data;
|
||||
|
||||
// 복제: 문서번호 초기화, 기안일 현재 시간으로
|
||||
const now = format(new Date(), 'yyyy-MM-dd HH:mm');
|
||||
setBasicInfo({
|
||||
...loadedBasicInfo,
|
||||
documentNo: '', // 새 문서이므로 문서번호 초기화
|
||||
draftDate: now,
|
||||
});
|
||||
|
||||
// 결재선/참조는 그대로 유지
|
||||
setApprovalLine(loadedApprovalLine);
|
||||
setReferences(loadedReferences);
|
||||
|
||||
// 문서 내용 복제
|
||||
if (loadedProposalData) setProposalData(loadedProposalData);
|
||||
if (loadedExpenseReportData) setExpenseReportData(loadedExpenseReportData);
|
||||
if (loadedExpenseEstimateData) setExpenseEstimateData(loadedExpenseEstimateData);
|
||||
|
||||
// React.StrictMode에서 useEffect 두 번 실행으로 인한 toast 중복 방지
|
||||
if (!copyToastShownRef.current) {
|
||||
copyToastShownRef.current = true;
|
||||
toast.info('문서가 복제되었습니다. 수정 후 상신해주세요.');
|
||||
}
|
||||
} else {
|
||||
toast.error(result.error || '원본 문서를 불러오는데 실패했습니다.');
|
||||
router.back();
|
||||
}
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('Failed to load document for copy:', error);
|
||||
toast.error('원본 문서를 불러오는데 실패했습니다.');
|
||||
router.back();
|
||||
} finally {
|
||||
setIsLoadingDocument(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadDocumentForCopy();
|
||||
}, [isCopyMode, copyFromId, router]);
|
||||
|
||||
// 비용견적서 항목 로드
|
||||
const loadExpenseEstimateItems = useCallback(async () => {
|
||||
setIsLoadingEstimate(true);
|
||||
try {
|
||||
const result = await getExpenseEstimateItems();
|
||||
if (result) {
|
||||
setExpenseEstimateData({
|
||||
items: result.items,
|
||||
totalExpense: result.totalExpense,
|
||||
accountBalance: result.accountBalance,
|
||||
finalDifference: result.finalDifference,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('Failed to load expense estimate items:', error);
|
||||
toast.error('비용견적서 항목을 불러오는데 실패했습니다.');
|
||||
} finally {
|
||||
setIsLoadingEstimate(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 문서 유형이 비용견적서로 변경되면 항목 로드
|
||||
useEffect(() => {
|
||||
if (basicInfo.documentType === 'expenseEstimate' && expenseEstimateData.items.length === 0) {
|
||||
loadExpenseEstimateItems();
|
||||
}
|
||||
}, [basicInfo.documentType, expenseEstimateData.items.length, loadExpenseEstimateItems]);
|
||||
|
||||
// 폼 데이터 수집
|
||||
const getFormData = useCallback(() => {
|
||||
return {
|
||||
basicInfo,
|
||||
approvalLine,
|
||||
references,
|
||||
proposalData: basicInfo.documentType === 'proposal' ? proposalData : undefined,
|
||||
expenseReportData: basicInfo.documentType === 'expenseReport' ? expenseReportData : undefined,
|
||||
expenseEstimateData: basicInfo.documentType === 'expenseEstimate' ? expenseEstimateData : undefined,
|
||||
};
|
||||
}, [basicInfo, approvalLine, references, proposalData, expenseReportData, expenseEstimateData]);
|
||||
|
||||
// 핸들러
|
||||
const handleBack = useCallback(() => {
|
||||
router.back();
|
||||
}, [router]);
|
||||
|
||||
const handleDelete = useCallback(() => {
|
||||
setIsDeleteDialogOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleDeleteConfirm = useCallback(async () => {
|
||||
setIsDeleteDialogOpen(false);
|
||||
|
||||
// 수정 모드: 실제 문서 삭제
|
||||
if (isEditMode && documentId) {
|
||||
startTransition(async () => {
|
||||
try {
|
||||
const result = await deleteApproval(parseInt(documentId));
|
||||
if (result.success) {
|
||||
invalidateDashboard('approval');
|
||||
toast.success('문서가 삭제되었습니다.');
|
||||
router.back();
|
||||
} else {
|
||||
toast.error(result.error || '문서 삭제에 실패했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('Delete error:', error);
|
||||
toast.error('문서 삭제 중 오류가 발생했습니다.');
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// 새 문서: 그냥 뒤로가기
|
||||
router.back();
|
||||
}
|
||||
}, [router, isEditMode, documentId]);
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
// 유효성 검사
|
||||
if (approvalLine.length === 0) {
|
||||
toast.error('결재선을 지정해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
startTransition(async () => {
|
||||
try {
|
||||
const formData = getFormData();
|
||||
|
||||
// 수정 모드: 수정 후 상신
|
||||
if (isEditMode && documentId) {
|
||||
const result = await updateAndSubmitApproval(parseInt(documentId), formData);
|
||||
if (result.success) {
|
||||
invalidateDashboard('approval');
|
||||
toast.success('수정 및 상신 완료', {
|
||||
description: `문서번호: ${result.data?.documentNo}`,
|
||||
});
|
||||
router.back();
|
||||
} else {
|
||||
toast.error(result.error || '문서 상신에 실패했습니다.');
|
||||
}
|
||||
} else {
|
||||
// 새 문서: 생성 후 상신
|
||||
const result = await createAndSubmitApproval(formData);
|
||||
if (result.success) {
|
||||
invalidateDashboard('approval');
|
||||
toast.success('상신 완료', {
|
||||
description: `문서번호: ${result.data?.documentNo}`,
|
||||
});
|
||||
router.back();
|
||||
} else {
|
||||
toast.error(result.error || '문서 상신에 실패했습니다.');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('Submit error:', error);
|
||||
toast.error('문서 상신 중 오류가 발생했습니다.');
|
||||
}
|
||||
});
|
||||
}, [approvalLine, getFormData, router, isEditMode, documentId]);
|
||||
|
||||
const handleSaveDraft = useCallback(async () => {
|
||||
startTransition(async () => {
|
||||
try {
|
||||
const formData = getFormData();
|
||||
|
||||
// 수정 모드: 기존 문서 업데이트
|
||||
if (isEditMode && documentId) {
|
||||
const result = await updateApproval(parseInt(documentId), formData);
|
||||
if (result.success) {
|
||||
invalidateDashboard('approval');
|
||||
toast.success('저장 완료', {
|
||||
description: `문서번호: ${result.data?.documentNo}`,
|
||||
});
|
||||
} else {
|
||||
toast.error(result.error || '저장에 실패했습니다.');
|
||||
}
|
||||
} else {
|
||||
// 새 문서: 임시저장
|
||||
const result = await createApproval(formData);
|
||||
if (result.success) {
|
||||
invalidateDashboard('approval');
|
||||
toast.success('임시저장 완료', {
|
||||
description: `문서번호: ${result.data?.documentNo}`,
|
||||
});
|
||||
// 문서번호 업데이트
|
||||
if (result.data?.documentNo) {
|
||||
setBasicInfo(prev => ({ ...prev, documentNo: result.data!.documentNo }));
|
||||
}
|
||||
} else {
|
||||
toast.error(result.error || '임시저장에 실패했습니다.');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('Save draft error:', error);
|
||||
toast.error('저장 중 오류가 발생했습니다.');
|
||||
}
|
||||
});
|
||||
}, [getFormData, isEditMode, documentId]);
|
||||
|
||||
// 미리보기 핸들러
|
||||
const handlePreview = useCallback(() => {
|
||||
setIsPreviewOpen(true);
|
||||
}, []);
|
||||
|
||||
// 미리보기용 데이터 변환
|
||||
const getPreviewData = useCallback((): ProposalDocumentData | ExpenseReportDocumentData | ExpenseEstimateDocumentData => {
|
||||
const drafter = {
|
||||
id: 'drafter-1',
|
||||
name: basicInfo.drafter,
|
||||
position: '사원',
|
||||
department: '개발팀',
|
||||
status: 'approved' as const,
|
||||
};
|
||||
const approvers = approvalLine.map((a, index) => ({
|
||||
id: a.id,
|
||||
name: a.name,
|
||||
position: a.position,
|
||||
department: a.department,
|
||||
status: (index === 0 ? 'pending' : 'none') as 'pending' | 'approved' | 'rejected' | 'none',
|
||||
}));
|
||||
|
||||
switch (basicInfo.documentType) {
|
||||
case 'expenseEstimate':
|
||||
return {
|
||||
documentNo: basicInfo.documentNo || '미발급',
|
||||
createdAt: basicInfo.draftDate,
|
||||
items: expenseEstimateData.items.map(item => ({
|
||||
id: item.id,
|
||||
expectedPaymentDate: item.expectedPaymentDate,
|
||||
category: item.category,
|
||||
amount: item.amount,
|
||||
vendor: item.vendor,
|
||||
account: item.memo || '',
|
||||
})),
|
||||
totalExpense: expenseEstimateData.totalExpense,
|
||||
accountBalance: expenseEstimateData.accountBalance,
|
||||
finalDifference: expenseEstimateData.finalDifference,
|
||||
approvers,
|
||||
drafter,
|
||||
};
|
||||
case 'expenseReport':
|
||||
return {
|
||||
documentNo: basicInfo.documentNo || '미발급',
|
||||
createdAt: basicInfo.draftDate,
|
||||
requestDate: expenseReportData.requestDate,
|
||||
paymentDate: expenseReportData.paymentDate,
|
||||
items: expenseReportData.items.map((item, index) => ({
|
||||
id: item.id,
|
||||
no: index + 1,
|
||||
description: item.description,
|
||||
amount: item.amount,
|
||||
note: item.note,
|
||||
})),
|
||||
cardInfo: expenseReportData.cardId || '-',
|
||||
totalAmount: expenseReportData.totalAmount,
|
||||
attachments: expenseReportData.attachments.map(f => f.name),
|
||||
approvers,
|
||||
drafter,
|
||||
};
|
||||
default: {
|
||||
// 이미 업로드된 파일 URL (Next.js 프록시 사용) + 새로 추가된 파일 미리보기 URL
|
||||
const uploadedFileUrls = (proposalData.uploadedFiles || []).map(f =>
|
||||
`/api/proxy/files/${f.id}/download`
|
||||
);
|
||||
const newFileUrls = proposalData.attachments.map(f => URL.createObjectURL(f));
|
||||
|
||||
return {
|
||||
documentNo: basicInfo.documentNo || '미발급',
|
||||
createdAt: basicInfo.draftDate,
|
||||
vendor: proposalData.vendor || '-',
|
||||
vendorPaymentDate: proposalData.vendorPaymentDate,
|
||||
title: proposalData.title || '(제목 없음)',
|
||||
description: proposalData.description || '-',
|
||||
reason: proposalData.reason || '-',
|
||||
estimatedCost: proposalData.estimatedCost,
|
||||
attachments: [...uploadedFileUrls, ...newFileUrls],
|
||||
approvers,
|
||||
drafter,
|
||||
};
|
||||
}
|
||||
}
|
||||
}, [basicInfo, approvalLine, proposalData, expenseReportData, expenseEstimateData]);
|
||||
|
||||
// 문서 유형별 폼 렌더링
|
||||
const renderDocumentTypeForm = () => {
|
||||
switch (basicInfo.documentType) {
|
||||
case 'proposal':
|
||||
return <ProposalForm data={proposalData} onChange={setProposalData} />;
|
||||
case 'expenseReport':
|
||||
return <ExpenseReportForm data={expenseReportData} onChange={setExpenseReportData} />;
|
||||
case 'expenseEstimate':
|
||||
return <ExpenseEstimateForm data={expenseEstimateData} onChange={setExpenseEstimateData} isLoading={isLoadingEstimate} />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// 현재 모드에 맞는 config 선택
|
||||
const currentConfig = isEditMode
|
||||
? documentEditConfig
|
||||
: isCopyMode
|
||||
? documentCopyConfig
|
||||
: documentCreateConfig;
|
||||
|
||||
// 헤더 액션 버튼 (config 배열 → 모바일 아이콘 패턴 자동 적용)
|
||||
const headerActionItems = useMemo<ActionItem[]>(() => [
|
||||
{ icon: Eye, label: '미리보기', onClick: handlePreview, variant: 'outline' },
|
||||
{ icon: Trash2, label: '삭제', onClick: handleDelete, variant: 'outline', hidden: !canDelete, disabled: isPending },
|
||||
{ icon: Send, label: '상신', onClick: handleSubmit, variant: 'outline', disabled: isPending || !canCreate, loading: isPending },
|
||||
{ icon: Save, label: isEditMode ? '저장' : '임시저장', onClick: handleSaveDraft, variant: 'outline', disabled: isPending, loading: isPending },
|
||||
], [handlePreview, handleDelete, handleSubmit, handleSaveDraft, isPending, isEditMode, canCreate, canDelete]);
|
||||
|
||||
// 폼 컨텐츠 렌더링
|
||||
const renderFormContent = useCallback(() => {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 기본 정보 */}
|
||||
<BasicInfoSection data={basicInfo} onChange={setBasicInfo} />
|
||||
|
||||
{/* 결재선 */}
|
||||
<ApprovalLineSection data={approvalLine} onChange={setApprovalLine} />
|
||||
|
||||
{/* 참조 */}
|
||||
<ReferenceSection data={references} onChange={setReferences} />
|
||||
|
||||
{/* 문서 유형별 폼 */}
|
||||
{renderDocumentTypeForm()}
|
||||
</div>
|
||||
);
|
||||
}, [basicInfo, approvalLine, references, renderDocumentTypeForm]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<IntegratedDetailTemplate
|
||||
config={currentConfig}
|
||||
mode={isEditMode ? 'edit' : 'create'}
|
||||
isLoading={isLoadingDocument}
|
||||
onBack={handleBack}
|
||||
renderForm={renderFormContent}
|
||||
headerActionItems={headerActionItems}
|
||||
/>
|
||||
|
||||
{/* 미리보기 모달 */}
|
||||
<DocumentDetailModal
|
||||
open={isPreviewOpen}
|
||||
onOpenChange={setIsPreviewOpen}
|
||||
documentType={basicInfo.documentType as ModalDocumentType}
|
||||
data={getPreviewData()}
|
||||
mode="draft"
|
||||
documentStatus="draft"
|
||||
onCopy={() => {
|
||||
// 복제: 현재 데이터를 기반으로 새 문서 등록 화면으로 이동
|
||||
if (documentId) {
|
||||
router.push(`/ko/approval/draft/new?copyFrom=${documentId}`);
|
||||
setIsPreviewOpen(false);
|
||||
}
|
||||
}}
|
||||
onSubmit={() => {
|
||||
setIsPreviewOpen(false);
|
||||
handleSubmit();
|
||||
}}
|
||||
/>
|
||||
|
||||
<DeleteConfirmDialog
|
||||
open={isDeleteDialogOpen}
|
||||
onOpenChange={setIsDeleteDialogOpen}
|
||||
onConfirm={handleDeleteConfirm}
|
||||
title="문서 삭제"
|
||||
description="작성 중인 문서를 삭제하시겠습니까?"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
106
src/components/approval_backup_v1/DocumentCreate/types.ts
Normal file
106
src/components/approval_backup_v1/DocumentCreate/types.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
// ===== 문서 작성 타입 정의 =====
|
||||
|
||||
// 업로드된 파일 정보
|
||||
export interface UploadedFile {
|
||||
id: number;
|
||||
name: string;
|
||||
url: string;
|
||||
size?: number;
|
||||
mime_type?: string;
|
||||
}
|
||||
|
||||
// 문서 유형
|
||||
export type DocumentType = 'proposal' | 'expenseReport' | 'expenseEstimate';
|
||||
|
||||
export const DOCUMENT_TYPE_OPTIONS: { value: DocumentType; label: string }[] = [
|
||||
{ value: 'proposal', label: '품의서' },
|
||||
{ value: 'expenseReport', label: '지출결의서' },
|
||||
{ value: 'expenseEstimate', label: '지출 예상 내역서' },
|
||||
];
|
||||
|
||||
// 결재자/참조자 정보
|
||||
export interface ApprovalPerson {
|
||||
id: string;
|
||||
department: string;
|
||||
position: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
// 기본 정보
|
||||
export interface BasicInfo {
|
||||
drafter: string;
|
||||
drafterPosition?: string;
|
||||
drafterDepartment?: string;
|
||||
draftDate: string;
|
||||
documentNo: string;
|
||||
documentType: DocumentType;
|
||||
}
|
||||
|
||||
// 품의서 데이터
|
||||
export interface ProposalData {
|
||||
vendorId: string; // 거래처 ID (API 연동)
|
||||
vendor: string; // 거래처명 (표시용)
|
||||
vendorPaymentDate: string;
|
||||
title: string;
|
||||
description: string;
|
||||
reason: string;
|
||||
estimatedCost: number;
|
||||
attachments: File[]; // 새로 추가할 파일
|
||||
uploadedFiles?: UploadedFile[]; // 이미 업로드된 파일
|
||||
}
|
||||
|
||||
// 지출결의서 항목
|
||||
export interface ExpenseReportItem {
|
||||
id: string;
|
||||
description: string;
|
||||
amount: number;
|
||||
note: string;
|
||||
}
|
||||
|
||||
// 지출결의서 데이터
|
||||
export interface ExpenseReportData {
|
||||
requestDate: string;
|
||||
paymentDate: string;
|
||||
items: ExpenseReportItem[];
|
||||
cardId: string;
|
||||
totalAmount: number;
|
||||
attachments: File[]; // 새로 추가할 파일
|
||||
uploadedFiles?: UploadedFile[]; // 이미 업로드된 파일
|
||||
}
|
||||
|
||||
// 지출 예상 내역서 항목
|
||||
export interface ExpenseEstimateItem {
|
||||
id: string;
|
||||
checked: boolean;
|
||||
expectedPaymentDate: string;
|
||||
category: string;
|
||||
amount: number;
|
||||
vendor: string;
|
||||
memo: string;
|
||||
}
|
||||
|
||||
// 지출 예상 내역서 데이터
|
||||
export interface ExpenseEstimateData {
|
||||
items: ExpenseEstimateItem[];
|
||||
totalExpense: number;
|
||||
accountBalance: number;
|
||||
finalDifference: number;
|
||||
}
|
||||
|
||||
// 전체 문서 데이터
|
||||
export interface DocumentFormData {
|
||||
basicInfo: BasicInfo;
|
||||
approvalLine: ApprovalPerson[];
|
||||
references: ApprovalPerson[];
|
||||
proposalData?: ProposalData;
|
||||
expenseReportData?: ExpenseReportData;
|
||||
expenseEstimateData?: ExpenseEstimateData;
|
||||
}
|
||||
|
||||
// 카드 옵션
|
||||
export const CARD_OPTIONS = [
|
||||
{ value: 'ibk-1234', label: 'IBK기업카드_1234 (카드명)' },
|
||||
{ value: 'shinhan-5678', label: '신한카드_5678 (카드명)' },
|
||||
{ value: 'kb-9012', label: 'KB국민카드_9012 (카드명)' },
|
||||
];
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
'use client';
|
||||
|
||||
import { CheckCircle, XCircle, Clock } from 'lucide-react';
|
||||
import type { Approver } from './types';
|
||||
|
||||
interface ApprovalLineBoxProps {
|
||||
drafter: Approver;
|
||||
approvers: Approver[];
|
||||
}
|
||||
|
||||
export function ApprovalLineBox({ drafter, approvers }: ApprovalLineBoxProps) {
|
||||
const getStatusIcon = (status: Approver['status']) => {
|
||||
switch (status) {
|
||||
case 'approved':
|
||||
return <CheckCircle className="w-3 h-3 text-green-600" />;
|
||||
case 'rejected':
|
||||
return <XCircle className="w-3 h-3 text-red-600" />;
|
||||
case 'pending':
|
||||
return <Clock className="w-3 h-3 text-yellow-600" />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="border border-gray-300 text-xs">
|
||||
{/* 헤더 */}
|
||||
<div className="grid grid-cols-[60px_1fr] border-b border-gray-300">
|
||||
<div className="bg-gray-100 p-1.5 text-center font-medium border-r border-gray-300">
|
||||
구분
|
||||
</div>
|
||||
<div className="grid" style={{ gridTemplateColumns: `repeat(${1 + approvers.length}, 1fr)` }}>
|
||||
<div className="bg-gray-100 p-1.5 text-center font-medium border-r border-gray-300">
|
||||
작성
|
||||
</div>
|
||||
{approvers.map((approver, index) => (
|
||||
<div
|
||||
key={approver.id}
|
||||
className={`bg-gray-100 p-1.5 text-center font-medium ${index < approvers.length - 1 ? 'border-r border-gray-300' : ''}`}
|
||||
>
|
||||
{approver.status === 'approved' ? '승인' : approver.status === 'rejected' ? '반려' : '결재'}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 이름 행 */}
|
||||
<div className="grid grid-cols-[60px_1fr] border-b border-gray-300">
|
||||
<div className="bg-gray-50 p-1.5 text-center border-r border-gray-300">이름</div>
|
||||
<div className="grid" style={{ gridTemplateColumns: `repeat(${1 + approvers.length}, 1fr)` }}>
|
||||
<div className="p-1.5 text-center border-r border-gray-300 flex items-center justify-center gap-1">
|
||||
{drafter.name}
|
||||
</div>
|
||||
{approvers.map((approver, index) => (
|
||||
<div
|
||||
key={approver.id}
|
||||
className={`p-1.5 text-center flex items-center justify-center gap-1 ${index < approvers.length - 1 ? 'border-r border-gray-300' : ''}`}
|
||||
>
|
||||
{getStatusIcon(approver.status)}
|
||||
{approver.name}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 부서 행 */}
|
||||
<div className="grid grid-cols-[60px_1fr]">
|
||||
<div className="bg-gray-50 p-1.5 text-center border-r border-gray-300">부서명</div>
|
||||
<div className="grid" style={{ gridTemplateColumns: `repeat(${1 + approvers.length}, 1fr)` }}>
|
||||
<div className="p-1.5 text-center border-r border-gray-300">
|
||||
{drafter.department}
|
||||
</div>
|
||||
{approvers.map((approver, index) => (
|
||||
<div
|
||||
key={approver.id}
|
||||
className={`p-1.5 text-center ${index < approvers.length - 1 ? 'border-r border-gray-300' : ''}`}
|
||||
>
|
||||
{approver.department}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
'use client';
|
||||
|
||||
import { DocumentViewer } from '@/components/document-system';
|
||||
import { ProposalDocument } from './ProposalDocument';
|
||||
import { ExpenseReportDocument } from './ExpenseReportDocument';
|
||||
import { ExpenseEstimateDocument } from './ExpenseEstimateDocument';
|
||||
import { LinkedDocumentContent } from './LinkedDocumentContent';
|
||||
import type {
|
||||
DocumentDetailModalProps,
|
||||
ProposalDocumentData,
|
||||
ExpenseReportDocumentData,
|
||||
ExpenseEstimateDocumentData,
|
||||
LinkedDocumentData,
|
||||
} from './types';
|
||||
|
||||
/**
|
||||
* 문서 상세 모달 V2
|
||||
*
|
||||
* DocumentViewer를 사용하여 통합 UI 제공
|
||||
* - 줌/드래그 기능 추가
|
||||
* - 모드에 따른 버튼 자동 설정
|
||||
*/
|
||||
export function DocumentDetailModalV2({
|
||||
open,
|
||||
onOpenChange,
|
||||
documentType,
|
||||
data,
|
||||
mode = 'inbox',
|
||||
documentStatus,
|
||||
onEdit,
|
||||
onCopy,
|
||||
onApprove,
|
||||
onReject,
|
||||
onSubmit,
|
||||
}: DocumentDetailModalProps) {
|
||||
// 문서 타입별 제목
|
||||
const getDocumentTitle = () => {
|
||||
switch (documentType) {
|
||||
case 'proposal':
|
||||
return '품의서';
|
||||
case 'expenseReport':
|
||||
return '지출결의서';
|
||||
case 'expenseEstimate':
|
||||
return '지출 예상 내역서';
|
||||
case 'document':
|
||||
return (data as LinkedDocumentData).templateName || '문서 결재';
|
||||
default:
|
||||
return '문서';
|
||||
}
|
||||
};
|
||||
|
||||
// 모드에 따른 프리셋 결정
|
||||
const getPreset = () => {
|
||||
// 기안함 모드 + 임시저장 상태: 복제, 상신, 인쇄
|
||||
if (mode === 'draft' && documentStatus === 'draft') {
|
||||
return 'approval-draft' as const;
|
||||
}
|
||||
// 결재함 모드: 수정, 반려, 승인, 인쇄
|
||||
if (mode === 'inbox') {
|
||||
return 'approval-inbox' as const;
|
||||
}
|
||||
// 그 외 (참조함 등): 인쇄만
|
||||
return 'readonly' as const;
|
||||
};
|
||||
|
||||
// 문서 콘텐츠 렌더링
|
||||
const renderDocument = () => {
|
||||
switch (documentType) {
|
||||
case 'proposal':
|
||||
return <ProposalDocument data={data as ProposalDocumentData} />;
|
||||
case 'expenseReport':
|
||||
return <ExpenseReportDocument data={data as ExpenseReportDocumentData} />;
|
||||
case 'expenseEstimate':
|
||||
return <ExpenseEstimateDocument data={data as ExpenseEstimateDocumentData} />;
|
||||
case 'document':
|
||||
return <LinkedDocumentContent data={data as LinkedDocumentData} />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<DocumentViewer
|
||||
title={getDocumentTitle()}
|
||||
subtitle={`${getDocumentTitle()} 상세`}
|
||||
preset={getPreset()}
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
onEdit={onEdit}
|
||||
onCopy={onCopy}
|
||||
onApprove={onApprove}
|
||||
onReject={onReject}
|
||||
onSubmit={onSubmit}
|
||||
>
|
||||
{renderDocument()}
|
||||
</DocumentViewer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 지출 예상 내역서 문서 컴포넌트
|
||||
*
|
||||
* 공통 컴포넌트 사용:
|
||||
* - DocumentHeader: centered 레이아웃 + customApproval (ApprovalLineBox)
|
||||
*/
|
||||
|
||||
import { Fragment } from 'react';
|
||||
import { ApprovalLineBox } from './ApprovalLineBox';
|
||||
import type { ExpenseEstimateDocumentData } from './types';
|
||||
import { DocumentHeader } from '@/components/document-system';
|
||||
import { formatNumber as formatCurrency } from '@/lib/utils/amount';
|
||||
|
||||
interface ExpenseEstimateDocumentProps {
|
||||
data: ExpenseEstimateDocumentData;
|
||||
}
|
||||
|
||||
export function ExpenseEstimateDocument({ data }: ExpenseEstimateDocumentProps) {
|
||||
|
||||
// 월별 그룹핑
|
||||
const groupedByMonth = data.items.reduce((acc, item) => {
|
||||
const month = item.expectedPaymentDate.substring(0, 7); // YYYY-MM
|
||||
if (!acc[month]) {
|
||||
acc[month] = [];
|
||||
}
|
||||
acc[month].push(item);
|
||||
return acc;
|
||||
}, {} as Record<string, typeof data.items>);
|
||||
|
||||
const getMonthSubtotal = (monthItems: typeof data.items) => {
|
||||
return monthItems.reduce((sum, item) => sum + item.amount, 0);
|
||||
};
|
||||
|
||||
const getMonthLabel = (month: string) => {
|
||||
const [year, mon] = month.split('-');
|
||||
return `${year}년 ${parseInt(mon)}월 계`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white p-8 min-h-full">
|
||||
{/* 문서 헤더 (공통 컴포넌트) */}
|
||||
<DocumentHeader
|
||||
title="지출 예상 내역서"
|
||||
subtitle={`문서번호: ${data.documentNo} | 작성일자: ${data.createdAt}`}
|
||||
layout="centered"
|
||||
customApproval={<ApprovalLineBox drafter={data.drafter} approvers={data.approvers} />}
|
||||
/>
|
||||
|
||||
{/* 문서 내용 */}
|
||||
<div className="border border-gray-300">
|
||||
{/* 지출 예상 내역서 헤더 */}
|
||||
<div className="bg-gray-800 text-white p-2 text-sm font-medium text-center">
|
||||
지출 예상 내역서 목록
|
||||
</div>
|
||||
|
||||
{/* 테이블 */}
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="bg-gray-100 border-b border-gray-300">
|
||||
<th className="p-2 text-center font-medium border-r border-gray-300 w-32">예상 지급일</th>
|
||||
<th className="p-2 text-left font-medium border-r border-gray-300">항목</th>
|
||||
<th className="p-2 text-right font-medium border-r border-gray-300 w-32">지출금액</th>
|
||||
<th className="p-2 text-left font-medium border-r border-gray-300 w-24">거래처</th>
|
||||
<th className="p-2 text-left font-medium w-40">계좌</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{Object.entries(groupedByMonth).map(([month, monthItems]) => (
|
||||
<Fragment key={month}>
|
||||
{monthItems.map((item) => (
|
||||
<tr key={item.id} className="border-b border-gray-300">
|
||||
<td className="p-2 text-center border-r border-gray-300">{item.expectedPaymentDate}</td>
|
||||
<td className="p-2 border-r border-gray-300">{item.category}</td>
|
||||
<td className="p-2 text-right border-r border-gray-300 text-blue-600 font-medium">
|
||||
{formatCurrency(item.amount)}
|
||||
</td>
|
||||
<td className="p-2 border-r border-gray-300">{item.vendor}</td>
|
||||
<td className="p-2">{item.account}</td>
|
||||
</tr>
|
||||
))}
|
||||
{/* 월별 소계 */}
|
||||
<tr className="bg-pink-50 border-b border-gray-300">
|
||||
<td colSpan={2} className="p-2 font-medium border-r border-gray-300">
|
||||
{getMonthLabel(month)}
|
||||
</td>
|
||||
<td className="p-2 text-right border-r border-gray-300 text-red-600 font-bold">
|
||||
{formatCurrency(getMonthSubtotal(monthItems))}
|
||||
</td>
|
||||
<td colSpan={2} className="p-2"></td>
|
||||
</tr>
|
||||
</Fragment>
|
||||
))}
|
||||
|
||||
{/* 지출 합계 */}
|
||||
<tr className="bg-gray-100 border-b border-gray-300">
|
||||
<td colSpan={2} className="p-3 font-semibold border-r border-gray-300">지출 합계</td>
|
||||
<td className="p-3 text-right border-r border-gray-300 text-red-600 font-bold">
|
||||
{formatCurrency(data.totalExpense)}
|
||||
</td>
|
||||
<td colSpan={2} className="p-3"></td>
|
||||
</tr>
|
||||
|
||||
{/* 계좌 잔액 */}
|
||||
<tr className="bg-gray-100 border-b border-gray-300">
|
||||
<td colSpan={2} className="p-3 font-semibold border-r border-gray-300">계좌 잔액</td>
|
||||
<td className="p-3 text-right border-r border-gray-300 font-bold">
|
||||
{formatCurrency(data.accountBalance)}
|
||||
</td>
|
||||
<td colSpan={2} className="p-3"></td>
|
||||
</tr>
|
||||
|
||||
{/* 최종 차액 */}
|
||||
<tr className="bg-gray-100">
|
||||
<td colSpan={2} className="p-3 font-semibold border-r border-gray-300">최종 차액</td>
|
||||
<td className={`p-3 text-right border-r border-gray-300 font-bold ${data.finalDifference >= 0 ? 'text-blue-600' : 'text-red-600'}`}>
|
||||
{formatCurrency(data.finalDifference)}
|
||||
</td>
|
||||
<td colSpan={2} className="p-3"></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 지출결의서 문서 컴포넌트
|
||||
*
|
||||
* 공통 컴포넌트 사용:
|
||||
* - DocumentHeader: centered 레이아웃 + customApproval (ApprovalLineBox)
|
||||
*/
|
||||
|
||||
import { ApprovalLineBox } from './ApprovalLineBox';
|
||||
import type { ExpenseReportDocumentData } from './types';
|
||||
import { DocumentHeader } from '@/components/document-system';
|
||||
import { formatNumber as formatCurrency } from '@/lib/utils/amount';
|
||||
|
||||
interface ExpenseReportDocumentProps {
|
||||
data: ExpenseReportDocumentData;
|
||||
}
|
||||
|
||||
export function ExpenseReportDocument({ data }: ExpenseReportDocumentProps) {
|
||||
|
||||
return (
|
||||
<div className="bg-white p-8 min-h-full">
|
||||
{/* 문서 헤더 (공통 컴포넌트) */}
|
||||
<DocumentHeader
|
||||
title="지출결의서"
|
||||
subtitle={`문서번호: ${data.documentNo} | 작성일자: ${data.createdAt}`}
|
||||
layout="centered"
|
||||
customApproval={<ApprovalLineBox drafter={data.drafter} approvers={data.approvers} />}
|
||||
/>
|
||||
|
||||
{/* 문서 내용 */}
|
||||
<div className="border border-gray-300">
|
||||
{/* 지출 요청일 / 결제일 */}
|
||||
<div className="grid grid-cols-2 border-b border-gray-300">
|
||||
<div className="flex border-r border-gray-300">
|
||||
<div className="w-28 bg-gray-100 p-3 font-medium text-sm border-r border-gray-300">
|
||||
지출 요청일
|
||||
</div>
|
||||
<div className="flex-1 p-3 text-sm">{data.requestDate || '-'}</div>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<div className="w-28 bg-gray-100 p-3 font-medium text-sm border-r border-gray-300">
|
||||
결제일
|
||||
</div>
|
||||
<div className="flex-1 p-3 text-sm">{data.paymentDate || '-'}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 지출결의서 내역 */}
|
||||
<div className="border-b border-gray-300">
|
||||
<div className="bg-gray-800 text-white p-2 text-sm font-medium text-center">
|
||||
지출결의서 내역
|
||||
</div>
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="bg-gray-100 border-b border-gray-300">
|
||||
<th className="p-2 text-center font-medium border-r border-gray-300 w-16">No.</th>
|
||||
<th className="p-2 text-left font-medium border-r border-gray-300">적요</th>
|
||||
<th className="p-2 text-right font-medium border-r border-gray-300 w-32">금액</th>
|
||||
<th className="p-2 text-left font-medium w-40">비고</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.items.length > 0 ? (
|
||||
data.items.map((item, index) => (
|
||||
<tr key={item.id} className="border-b border-gray-300">
|
||||
<td className="p-2 text-center border-r border-gray-300">{index + 1}</td>
|
||||
<td className="p-2 border-r border-gray-300">{item.description}</td>
|
||||
<td className="p-2 text-right border-r border-gray-300">
|
||||
{formatCurrency(item.amount)}
|
||||
</td>
|
||||
<td className="p-2">{item.note}</td>
|
||||
</tr>
|
||||
))
|
||||
) : (
|
||||
<>
|
||||
{[1, 2, 3].map((num) => (
|
||||
<tr key={num} className="border-b border-gray-300">
|
||||
<td className="p-2 text-center border-r border-gray-300">{num}</td>
|
||||
<td className="p-2 border-r border-gray-300"> </td>
|
||||
<td className="p-2 border-r border-gray-300"> </td>
|
||||
<td className="p-2"> </td>
|
||||
</tr>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* 법인카드 / 총 비용 */}
|
||||
<div className="grid grid-cols-2 border-b border-gray-300">
|
||||
<div className="flex border-r border-gray-300">
|
||||
<div className="w-28 bg-gray-100 p-3 font-medium text-sm border-r border-gray-300">
|
||||
법인카드
|
||||
</div>
|
||||
<div className="flex-1 p-3 text-sm">{data.cardInfo || '-'}</div>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<div className="w-28 bg-gray-100 p-3 font-medium text-sm border-r border-gray-300">
|
||||
총 비용
|
||||
</div>
|
||||
<div className="flex-1 p-3 text-sm font-semibold">
|
||||
{formatCurrency(data.totalAmount)}원
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 참고 이미지 */}
|
||||
<div>
|
||||
<div className="bg-gray-800 text-white p-2 text-sm font-medium text-center">
|
||||
참고 이미지
|
||||
</div>
|
||||
<div className="min-h-[150px] p-4 bg-gray-50">
|
||||
{data.attachments && data.attachments.length > 0 ? (
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
{data.attachments.map((url, index) => (
|
||||
<img
|
||||
key={index}
|
||||
src={url}
|
||||
alt={`첨부 이미지 ${index + 1}`}
|
||||
className="w-full h-32 object-cover rounded border"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center text-gray-400 py-8">
|
||||
첨부된 이미지가 없습니다
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 연결 문서 콘텐츠 컴포넌트
|
||||
*
|
||||
* 문서관리에서 생성된 검사 성적서, 작업일지 등을
|
||||
* 결재함 모달에서 렌더링합니다.
|
||||
*/
|
||||
|
||||
import { ApprovalLineBox } from './ApprovalLineBox';
|
||||
import type { LinkedDocumentData } from './types';
|
||||
import { DocumentHeader } from '@/components/document-system';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { FileText, Paperclip } from 'lucide-react';
|
||||
|
||||
interface LinkedDocumentContentProps {
|
||||
data: LinkedDocumentData;
|
||||
}
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
DRAFT: '임시저장',
|
||||
PENDING: '진행중',
|
||||
APPROVED: '승인완료',
|
||||
REJECTED: '반려',
|
||||
CANCELLED: '회수',
|
||||
};
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
DRAFT: 'bg-gray-100 text-gray-800',
|
||||
PENDING: 'bg-yellow-100 text-yellow-800',
|
||||
APPROVED: 'bg-green-100 text-green-800',
|
||||
REJECTED: 'bg-red-100 text-red-800',
|
||||
CANCELLED: 'bg-gray-100 text-gray-600',
|
||||
};
|
||||
|
||||
export function LinkedDocumentContent({ data }: LinkedDocumentContentProps) {
|
||||
return (
|
||||
<div className="bg-white p-8 min-h-full">
|
||||
{/* 문서 헤더 (공통 컴포넌트) */}
|
||||
<DocumentHeader
|
||||
title={data.templateName || '문서 결재'}
|
||||
subtitle={`문서번호: ${data.documentNo} | 작성일자: ${data.createdAt?.substring(0, 10) || '-'}`}
|
||||
layout="centered"
|
||||
customApproval={<ApprovalLineBox drafter={data.drafter} approvers={data.approvers} />}
|
||||
/>
|
||||
|
||||
{/* 문서 기본 정보 */}
|
||||
<div className="border border-gray-300 mb-4">
|
||||
<div className="flex border-b border-gray-300">
|
||||
<div className="w-28 bg-gray-100 p-3 font-medium text-sm border-r border-gray-300">
|
||||
문서 제목
|
||||
</div>
|
||||
<div className="flex-1 p-3 text-sm">{data.title || '-'}</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 border-b border-gray-300">
|
||||
<div className="flex border-r border-gray-300">
|
||||
<div className="w-28 bg-gray-100 p-3 font-medium text-sm border-r border-gray-300">
|
||||
양식
|
||||
</div>
|
||||
<div className="flex-1 p-3 text-sm flex items-center gap-2">
|
||||
<FileText className="w-4 h-4 text-gray-500" />
|
||||
{data.templateName || '-'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<div className="w-28 bg-gray-100 p-3 font-medium text-sm border-r border-gray-300">
|
||||
상태
|
||||
</div>
|
||||
<div className="flex-1 p-3 text-sm">
|
||||
<Badge className={STATUS_COLORS[data.status] || 'bg-gray-100 text-gray-800'}>
|
||||
{STATUS_LABELS[data.status] || data.status}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 문서 데이터 */}
|
||||
{data.documentData.length > 0 && (
|
||||
<div className="border border-gray-300 mb-4">
|
||||
<div className="bg-gray-800 text-white p-2 text-sm font-medium text-center">
|
||||
문서 내용
|
||||
</div>
|
||||
<div className="divide-y divide-gray-300">
|
||||
{data.documentData.map((field, index) => (
|
||||
<div key={index} className="flex">
|
||||
<div className="w-36 bg-gray-100 p-3 font-medium text-sm border-r border-gray-300">
|
||||
{field.fieldLabel}
|
||||
</div>
|
||||
<div className="flex-1 p-3 text-sm whitespace-pre-wrap">
|
||||
{renderFieldValue(field.value)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 첨부파일 */}
|
||||
{data.attachments && data.attachments.length > 0 && (
|
||||
<div className="border border-gray-300">
|
||||
<div className="bg-gray-800 text-white p-2 text-sm font-medium text-center">
|
||||
첨부파일
|
||||
</div>
|
||||
<div className="p-4 space-y-2">
|
||||
{data.attachments.map((file) => (
|
||||
<a
|
||||
key={file.id}
|
||||
href={file.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2 text-sm text-blue-600 hover:underline"
|
||||
>
|
||||
<Paperclip className="w-4 h-4" />
|
||||
{file.name}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function renderFieldValue(value: unknown): string {
|
||||
if (value === null || value === undefined) return '-';
|
||||
if (typeof value === 'string') return value || '-';
|
||||
if (typeof value === 'number') return String(value);
|
||||
if (typeof value === 'boolean') return value ? '예' : '아니오';
|
||||
if (Array.isArray(value)) return value.join(', ') || '-';
|
||||
if (typeof value === 'object') return JSON.stringify(value, null, 2);
|
||||
return String(value);
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 품의서 문서 컴포넌트
|
||||
*
|
||||
* 공통 컴포넌트 사용:
|
||||
* - DocumentHeader: centered 레이아웃 + customApproval (ApprovalLineBox)
|
||||
*/
|
||||
|
||||
import { ApprovalLineBox } from './ApprovalLineBox';
|
||||
import type { ProposalDocumentData } from './types';
|
||||
import { DocumentHeader } from '@/components/document-system';
|
||||
import { formatNumber as formatCurrency } from '@/lib/utils/amount';
|
||||
|
||||
interface ProposalDocumentProps {
|
||||
data: ProposalDocumentData;
|
||||
}
|
||||
|
||||
export function ProposalDocument({ data }: ProposalDocumentProps) {
|
||||
|
||||
return (
|
||||
<div className="bg-white p-8 min-h-full">
|
||||
{/* 문서 헤더 (공통 컴포넌트) */}
|
||||
<DocumentHeader
|
||||
title="품의서"
|
||||
subtitle={`문서번호: ${data.documentNo} | 작성일자: ${data.createdAt}`}
|
||||
layout="centered"
|
||||
customApproval={<ApprovalLineBox drafter={data.drafter} approvers={data.approvers} />}
|
||||
/>
|
||||
|
||||
{/* 문서 내용 */}
|
||||
<div className="border border-gray-300">
|
||||
{/* 구매처 정보 */}
|
||||
<div className="grid grid-cols-2 border-b border-gray-300">
|
||||
<div className="flex border-r border-gray-300">
|
||||
<div className="w-28 bg-gray-100 p-3 font-medium text-sm border-r border-gray-300">
|
||||
구매처
|
||||
</div>
|
||||
<div className="flex-1 p-3 text-sm">{data.vendor || '-'}</div>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<div className="w-28 bg-gray-100 p-3 font-medium text-sm border-r border-gray-300">
|
||||
구매처 결제일
|
||||
</div>
|
||||
<div className="flex-1 p-3 text-sm">{data.vendorPaymentDate || '-'}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 제목 */}
|
||||
<div className="flex border-b border-gray-300">
|
||||
<div className="w-28 bg-gray-100 p-3 font-medium text-sm border-r border-gray-300">
|
||||
제목
|
||||
</div>
|
||||
<div className="flex-1 p-3 text-sm">{data.title || '-'}</div>
|
||||
</div>
|
||||
|
||||
{/* 품의 내역 */}
|
||||
<div className="flex border-b border-gray-300">
|
||||
<div className="w-28 bg-gray-100 p-3 font-medium text-sm border-r border-gray-300">
|
||||
품의 내역
|
||||
</div>
|
||||
<div className="flex-1 p-3 text-sm min-h-[100px] whitespace-pre-wrap">
|
||||
{data.description || '-'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 품의 사유 */}
|
||||
<div className="flex border-b border-gray-300">
|
||||
<div className="w-28 bg-gray-100 p-3 font-medium text-sm border-r border-gray-300">
|
||||
품의 사유
|
||||
</div>
|
||||
<div className="flex-1 p-3 text-sm min-h-[100px] whitespace-pre-wrap">
|
||||
{data.reason || '-'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 예상 비용 */}
|
||||
<div className="flex border-b border-gray-300">
|
||||
<div className="w-28 bg-gray-100 p-3 font-medium text-sm border-r border-gray-300">
|
||||
예상 비용
|
||||
</div>
|
||||
<div className="flex-1 p-3 text-sm font-semibold">
|
||||
{formatCurrency(data.estimatedCost)}원
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 참고 이미지 */}
|
||||
<div>
|
||||
<div className="bg-gray-800 text-white p-2 text-sm font-medium text-center">
|
||||
참고 이미지
|
||||
</div>
|
||||
<div className="min-h-[150px] p-4 bg-gray-50">
|
||||
{data.attachments && data.attachments.length > 0 ? (
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
{data.attachments.map((url, index) => (
|
||||
<img
|
||||
key={index}
|
||||
src={url}
|
||||
alt={`첨부 이미지 ${index + 1}`}
|
||||
className="w-full h-32 object-cover rounded border"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center text-gray-400 py-8">
|
||||
첨부된 이미지가 없습니다
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
182
src/components/approval_backup_v1/DocumentDetail/index.tsx
Normal file
182
src/components/approval_backup_v1/DocumentDetail/index.tsx
Normal file
@@ -0,0 +1,182 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
VisuallyHidden,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Copy,
|
||||
Edit,
|
||||
X as XIcon,
|
||||
CheckCircle,
|
||||
Printer,
|
||||
Send,
|
||||
} from 'lucide-react';
|
||||
import { ProposalDocument } from './ProposalDocument';
|
||||
import { ExpenseReportDocument } from './ExpenseReportDocument';
|
||||
import { ExpenseEstimateDocument } from './ExpenseEstimateDocument';
|
||||
import { printArea } from '@/lib/print-utils';
|
||||
import type {
|
||||
DocumentDetailModalProps,
|
||||
ProposalDocumentData,
|
||||
ExpenseReportDocumentData,
|
||||
ExpenseEstimateDocumentData,
|
||||
} from './types';
|
||||
|
||||
export function DocumentDetailModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
documentType,
|
||||
data,
|
||||
mode = 'inbox', // 기본값: 결재함 (승인/반려 표시)
|
||||
documentStatus,
|
||||
onEdit,
|
||||
onCopy,
|
||||
onApprove,
|
||||
onReject,
|
||||
onSubmit,
|
||||
}: DocumentDetailModalProps) {
|
||||
// 기안함 모드에서 임시저장 상태일 때만 수정/상신 가능
|
||||
const getDocumentTitle = () => {
|
||||
switch (documentType) {
|
||||
case 'proposal':
|
||||
return '품의서';
|
||||
case 'expenseReport':
|
||||
return '지출결의서';
|
||||
case 'expenseEstimate':
|
||||
return '지출 예상 내역서';
|
||||
default:
|
||||
return '문서';
|
||||
}
|
||||
};
|
||||
|
||||
const handlePrint = () => {
|
||||
printArea({ title: `${getDocumentTitle()} 인쇄` });
|
||||
};
|
||||
|
||||
const renderDocument = () => {
|
||||
switch (documentType) {
|
||||
case 'proposal':
|
||||
return <ProposalDocument data={data as ProposalDocumentData} />;
|
||||
case 'expenseReport':
|
||||
return <ExpenseReportDocument data={data as ExpenseReportDocumentData} />;
|
||||
case 'expenseEstimate':
|
||||
return <ExpenseEstimateDocument data={data as ExpenseEstimateDocumentData} />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-[95vw] md:max-w-[800px] lg:max-w-[900px] h-[95vh] p-0 flex flex-col gap-0 overflow-hidden [&>button]:hidden">
|
||||
<VisuallyHidden>
|
||||
<DialogTitle>{getDocumentTitle()} 상세</DialogTitle>
|
||||
</VisuallyHidden>
|
||||
|
||||
{/* 헤더 영역 - 고정 (인쇄 시 숨김) */}
|
||||
<div className="print-hidden flex-shrink-0 flex items-center justify-between px-6 py-4 border-b bg-white">
|
||||
<h2 className="text-lg font-semibold">{getDocumentTitle()} 상세</h2>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => onOpenChange(false)}
|
||||
className="h-8 w-8"
|
||||
>
|
||||
<XIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 버튼 영역 - 고정 (인쇄 시 숨김) */}
|
||||
<div className="print-hidden flex-shrink-0 flex items-center gap-2 px-6 py-3 bg-muted/30 border-b">
|
||||
{/* 기안함 모드 + 임시저장 상태: 복제, 상신, 인쇄 */}
|
||||
{mode === 'draft' && documentStatus === 'draft' && (
|
||||
<>
|
||||
<Button variant="outline" size="sm" onClick={onCopy}>
|
||||
<Copy className="h-4 w-4 mr-1" />
|
||||
복제
|
||||
</Button>
|
||||
<Button variant="default" size="sm" onClick={onSubmit} className="bg-blue-600 hover:bg-blue-700">
|
||||
<Send className="h-4 w-4 mr-1" />
|
||||
상신
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 기안함 모드 + 결재대기 이후 상태: 인쇄만 (버튼 없음, 아래 인쇄 버튼만 표시) */}
|
||||
|
||||
{/* 결재함 모드: 수정, 반려, 승인, 인쇄 */}
|
||||
{mode === 'inbox' && (
|
||||
<>
|
||||
<Button variant="outline" size="sm" onClick={onEdit}>
|
||||
<Edit className="h-4 w-4 mr-1" />
|
||||
수정
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={onReject} className="text-red-600 hover:text-red-700">
|
||||
<XIcon className="h-4 w-4 mr-1" />
|
||||
반려
|
||||
</Button>
|
||||
<Button variant="default" size="sm" onClick={onApprove} className="bg-blue-600 hover:bg-blue-700">
|
||||
<CheckCircle className="h-4 w-4 mr-1" />
|
||||
승인
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Button variant="outline" size="sm" onClick={handlePrint}>
|
||||
<Printer className="h-4 w-4 mr-1" />
|
||||
인쇄
|
||||
</Button>
|
||||
|
||||
{/* TODO: 공유 기능 추가 예정 - PDF 다운로드, 이메일, 팩스, 카카오톡 공유 */}
|
||||
{/* <DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm">
|
||||
<Share2 className="h-4 w-4 mr-1" />
|
||||
공유
|
||||
<ChevronDown className="h-3 w-3 ml-1" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={handleSharePdf}>
|
||||
<FileText className="h-4 w-4 mr-2" />
|
||||
PDF 다운로드
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={handleShareEmail}>
|
||||
<Mail className="h-4 w-4 mr-2" />
|
||||
이메일
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={handleShareFax}>
|
||||
<Phone className="h-4 w-4 mr-2" />
|
||||
팩스
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={handleShareKakao}>
|
||||
<MessageCircle className="h-4 w-4 mr-2" />
|
||||
카카오톡
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu> */}
|
||||
</div>
|
||||
|
||||
{/* 문서 영역 - 스크롤 (인쇄 시 이 영역만 출력) */}
|
||||
<div className="print-area flex-1 overflow-y-auto bg-gray-100 p-6">
|
||||
<div className="max-w-[210mm] mx-auto bg-white shadow-lg rounded-lg">
|
||||
{renderDocument()}
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// Re-export types and components
|
||||
export type { DocumentType, DocumentDetailModalProps } from './types';
|
||||
export { ProposalDocument } from './ProposalDocument';
|
||||
export { ExpenseReportDocument } from './ExpenseReportDocument';
|
||||
export { ExpenseEstimateDocument } from './ExpenseEstimateDocument';
|
||||
|
||||
// V2 - DocumentViewer 기반
|
||||
export { DocumentDetailModalV2 } from './DocumentDetailModalV2';
|
||||
117
src/components/approval_backup_v1/DocumentDetail/types.ts
Normal file
117
src/components/approval_backup_v1/DocumentDetail/types.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
// ===== 문서 상세 모달 타입 정의 =====
|
||||
|
||||
export type DocumentType = 'proposal' | 'expenseReport' | 'expenseEstimate' | 'document';
|
||||
|
||||
// 결재자 정보
|
||||
export interface Approver {
|
||||
id: string;
|
||||
name: string;
|
||||
position: string;
|
||||
department: string;
|
||||
status: 'pending' | 'approved' | 'rejected' | 'none';
|
||||
approvedAt?: string;
|
||||
}
|
||||
|
||||
// 품의서 데이터
|
||||
export interface ProposalDocumentData {
|
||||
documentNo: string;
|
||||
createdAt: string;
|
||||
vendor: string;
|
||||
vendorPaymentDate: string;
|
||||
title: string;
|
||||
description: string;
|
||||
reason: string;
|
||||
estimatedCost: number;
|
||||
attachments?: string[];
|
||||
approvers: Approver[];
|
||||
drafter: Approver;
|
||||
}
|
||||
|
||||
// 지출결의서 항목
|
||||
export interface ExpenseReportItem {
|
||||
id: string;
|
||||
no: number;
|
||||
description: string;
|
||||
amount: number;
|
||||
note: string;
|
||||
}
|
||||
|
||||
// 지출결의서 데이터
|
||||
export interface ExpenseReportDocumentData {
|
||||
documentNo: string;
|
||||
createdAt: string;
|
||||
requestDate: string;
|
||||
paymentDate: string;
|
||||
items: ExpenseReportItem[];
|
||||
cardInfo: string;
|
||||
totalAmount: number;
|
||||
attachments?: string[];
|
||||
approvers: Approver[];
|
||||
drafter: Approver;
|
||||
}
|
||||
|
||||
// 지출 예상 내역서 항목
|
||||
export interface ExpenseEstimateItem {
|
||||
id: string;
|
||||
expectedPaymentDate: string;
|
||||
category: string;
|
||||
amount: number;
|
||||
vendor: string;
|
||||
account: string;
|
||||
}
|
||||
|
||||
// 지출 예상 내역서 데이터
|
||||
export interface ExpenseEstimateDocumentData {
|
||||
documentNo: string;
|
||||
createdAt: string;
|
||||
items: ExpenseEstimateItem[];
|
||||
totalExpense: number;
|
||||
accountBalance: number;
|
||||
finalDifference: number;
|
||||
approvers: Approver[];
|
||||
drafter: Approver;
|
||||
}
|
||||
|
||||
// 연결 문서 데이터 (검사 성적서, 작업일지 등 문서관리에서 생성된 문서)
|
||||
export interface LinkedDocumentData {
|
||||
documentNo: string;
|
||||
createdAt: string;
|
||||
title: string;
|
||||
templateName: string;
|
||||
templateCode: string;
|
||||
status: string;
|
||||
workOrderId?: number;
|
||||
documentData: Array<{
|
||||
fieldKey: string;
|
||||
fieldLabel: string;
|
||||
value: unknown;
|
||||
}>;
|
||||
approvers: Approver[];
|
||||
drafter: Approver;
|
||||
attachments?: Array<{
|
||||
id: number;
|
||||
name: string;
|
||||
url: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
// 문서 상세 모달 모드
|
||||
export type DocumentDetailMode = 'draft' | 'inbox' | 'reference';
|
||||
|
||||
// 문서 상태 (기안함 기준)
|
||||
export type DocumentStatus = 'draft' | 'pending' | 'approved' | 'rejected';
|
||||
|
||||
// 문서 상세 모달 Props
|
||||
export interface DocumentDetailModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
documentType: DocumentType;
|
||||
data: ProposalDocumentData | ExpenseReportDocumentData | ExpenseEstimateDocumentData | LinkedDocumentData;
|
||||
mode?: DocumentDetailMode; // 'draft': 기안함 (상신), 'inbox': 결재함 (승인/반려)
|
||||
documentStatus?: DocumentStatus; // 문서 상태 (임시저장일 때만 수정/상신 가능)
|
||||
onEdit?: () => void;
|
||||
onCopy?: () => void;
|
||||
onApprove?: () => void;
|
||||
onReject?: () => void;
|
||||
onSubmit?: () => void; // 상신 콜백
|
||||
}
|
||||
257
src/components/approval_backup_v1/DraftBox/actions.ts
Normal file
257
src/components/approval_backup_v1/DraftBox/actions.ts
Normal file
@@ -0,0 +1,257 @@
|
||||
/**
|
||||
* 기안함 서버 액션
|
||||
*
|
||||
* API Endpoints:
|
||||
* - GET /api/v1/approvals/drafts - 기안함 목록 조회
|
||||
* - GET /api/v1/approvals/drafts/summary - 기안함 현황 카드
|
||||
* - GET /api/v1/approvals/{id} - 결재 문서 상세
|
||||
* - DELETE /api/v1/approvals/{id} - 결재 문서 삭제 (임시저장만)
|
||||
* - POST /api/v1/approvals/{id}/submit - 결재 상신
|
||||
* - POST /api/v1/approvals/{id}/cancel - 결재 회수
|
||||
*/
|
||||
|
||||
'use server';
|
||||
|
||||
|
||||
import { executeServerAction, type ActionResult } from '@/lib/api/execute-server-action';
|
||||
import { buildApiUrl } from '@/lib/api/query-params';
|
||||
import type { PaginatedApiResponse } from '@/lib/api/types';
|
||||
import type { DraftRecord, DocumentStatus, Approver } from './types';
|
||||
import { formatDate } from '@/lib/utils/date';
|
||||
|
||||
// ============================================
|
||||
// API 응답 타입 정의
|
||||
// ============================================
|
||||
|
||||
interface DraftsSummary {
|
||||
total: number;
|
||||
draft: number;
|
||||
pending: number;
|
||||
approved: number;
|
||||
rejected: number;
|
||||
}
|
||||
|
||||
// API 응답의 결재 문서 타입
|
||||
interface ApprovalApiData {
|
||||
id: number;
|
||||
document_number: string;
|
||||
title: string;
|
||||
status: string;
|
||||
form?: {
|
||||
id: number;
|
||||
name: string;
|
||||
code: string;
|
||||
category: string;
|
||||
};
|
||||
drafter?: {
|
||||
id: number;
|
||||
name: string;
|
||||
};
|
||||
steps?: ApprovalStepApiData[];
|
||||
content?: Record<string, unknown>;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
interface ApprovalStepApiData {
|
||||
id: number;
|
||||
step_order: number;
|
||||
step_type: string;
|
||||
approver_id: number;
|
||||
approver?: {
|
||||
id: number;
|
||||
name: string;
|
||||
tenant_profile?: {
|
||||
position_key?: string;
|
||||
department?: { id: number; name: string };
|
||||
};
|
||||
};
|
||||
status: string;
|
||||
processed_at?: string;
|
||||
comment?: string;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 헬퍼 함수
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* API 상태 → 프론트엔드 상태 변환
|
||||
*/
|
||||
function mapApiStatus(apiStatus: string): DocumentStatus {
|
||||
const statusMap: Record<string, DocumentStatus> = {
|
||||
'draft': 'draft',
|
||||
'pending': 'pending',
|
||||
'in_progress': 'inProgress',
|
||||
'approved': 'approved',
|
||||
'rejected': 'rejected',
|
||||
};
|
||||
return statusMap[apiStatus] || 'draft';
|
||||
}
|
||||
|
||||
/**
|
||||
* 결재자 상태 변환
|
||||
*/
|
||||
function mapApproverStatus(stepStatus: string): Approver['status'] {
|
||||
const statusMap: Record<string, Approver['status']> = {
|
||||
'pending': 'pending',
|
||||
'approved': 'approved',
|
||||
'rejected': 'rejected',
|
||||
};
|
||||
return statusMap[stepStatus] || 'none';
|
||||
}
|
||||
|
||||
/**
|
||||
* 직책 코드 → 한글 변환
|
||||
*/
|
||||
function getPositionLabel(positionKey: string | null | undefined): string {
|
||||
if (!positionKey) return '';
|
||||
const labels: Record<string, string> = {
|
||||
'EXECUTIVE': '임원',
|
||||
'DIRECTOR': '부장',
|
||||
'MANAGER': '과장',
|
||||
'SENIOR': '대리',
|
||||
'STAFF': '사원',
|
||||
'INTERN': '인턴',
|
||||
};
|
||||
return labels[positionKey] ?? positionKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* API 데이터 → 프론트엔드 데이터 변환
|
||||
*/
|
||||
function transformApiToFrontend(data: ApprovalApiData): DraftRecord {
|
||||
// approval 타입 결재자만 필터링 (reference 제외)
|
||||
const approvers: Approver[] = (data.steps || [])
|
||||
.filter((step) => step.step_type === 'approval')
|
||||
.map((step) => ({
|
||||
id: String(step.approver_id),
|
||||
name: step.approver?.name || '',
|
||||
position: getPositionLabel(step.approver?.tenant_profile?.position_key),
|
||||
department: step.approver?.tenant_profile?.department?.name || '',
|
||||
status: mapApproverStatus(step.status),
|
||||
approvedAt: step.processed_at,
|
||||
}));
|
||||
|
||||
// drafter의 tenant_profile에서 직책/부서 추출
|
||||
const drafterProfile = (data.drafter as { tenant_profile?: { position_key?: string; department?: { name: string } } })?.tenant_profile;
|
||||
|
||||
return {
|
||||
id: String(data.id),
|
||||
documentNo: data.document_number,
|
||||
documentType: data.form?.name || '',
|
||||
documentTypeCode: data.form?.code || 'proposal',
|
||||
title: data.title,
|
||||
draftDate: formatDate(data.created_at),
|
||||
drafter: data.drafter?.name || '',
|
||||
drafterPosition: getPositionLabel(drafterProfile?.position_key),
|
||||
drafterDepartment: drafterProfile?.department?.name || '',
|
||||
approvers,
|
||||
status: mapApiStatus(data.status),
|
||||
content: data.content,
|
||||
createdAt: data.created_at,
|
||||
updatedAt: data.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
const DRAFT_STATUS_MAP: Record<string, string> = {
|
||||
'draft': 'draft', 'pending': 'pending', 'inProgress': 'in_progress',
|
||||
'approved': 'approved', 'rejected': 'rejected',
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// API 함수
|
||||
// ============================================
|
||||
|
||||
export async function getDrafts(params?: {
|
||||
page?: number; per_page?: number; search?: string; status?: string;
|
||||
sort_by?: string; sort_dir?: 'asc' | 'desc';
|
||||
}): Promise<{ data: DraftRecord[]; total: number; lastPage: number; __authError?: boolean }> {
|
||||
const result = await executeServerAction<PaginatedApiResponse<ApprovalApiData>>({
|
||||
url: buildApiUrl('/api/v1/approvals/drafts', {
|
||||
page: params?.page,
|
||||
per_page: params?.per_page,
|
||||
search: params?.search,
|
||||
status: params?.status && params.status !== 'all' ? (DRAFT_STATUS_MAP[params.status] || params.status) : undefined,
|
||||
sort_by: params?.sort_by,
|
||||
sort_dir: params?.sort_dir,
|
||||
}),
|
||||
errorMessage: '기안함 목록 조회에 실패했습니다.',
|
||||
});
|
||||
|
||||
if (result.__authError) return { data: [], total: 0, lastPage: 1, __authError: true };
|
||||
if (!result.success || !result.data?.data) return { data: [], total: 0, lastPage: 1 };
|
||||
|
||||
return {
|
||||
data: result.data.data.map(transformApiToFrontend),
|
||||
total: result.data.total,
|
||||
lastPage: result.data.last_page,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getDraftsSummary(): Promise<DraftsSummary | null> {
|
||||
const result = await executeServerAction<DraftsSummary>({
|
||||
url: buildApiUrl('/api/v1/approvals/drafts/summary'),
|
||||
errorMessage: '기안함 현황 조회에 실패했습니다.',
|
||||
});
|
||||
return result.success ? result.data || null : null;
|
||||
}
|
||||
|
||||
export async function getDraftById(id: string): Promise<DraftRecord | null> {
|
||||
const result = await executeServerAction({
|
||||
url: buildApiUrl(`/api/v1/approvals/${id}`),
|
||||
transform: (data: ApprovalApiData) => transformApiToFrontend(data),
|
||||
errorMessage: '결재 문서 조회에 실패했습니다.',
|
||||
});
|
||||
return result.success ? result.data || null : null;
|
||||
}
|
||||
|
||||
export async function deleteDraft(id: string): Promise<ActionResult> {
|
||||
return executeServerAction({
|
||||
url: buildApiUrl(`/api/v1/approvals/${id}`),
|
||||
method: 'DELETE',
|
||||
errorMessage: '결재 문서 삭제에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteDrafts(ids: string[]): Promise<{ success: boolean; failedIds?: string[]; error?: string }> {
|
||||
const failedIds: string[] = [];
|
||||
for (const id of ids) {
|
||||
const result = await deleteDraft(id);
|
||||
if (!result.success) failedIds.push(id);
|
||||
}
|
||||
if (failedIds.length > 0) {
|
||||
return { success: false, failedIds, error: `${failedIds.length}건의 삭제에 실패했습니다.` };
|
||||
}
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function submitDraft(id: string): Promise<ActionResult> {
|
||||
return executeServerAction({
|
||||
url: buildApiUrl(`/api/v1/approvals/${id}/submit`),
|
||||
method: 'POST',
|
||||
body: {},
|
||||
errorMessage: '결재 상신에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
export async function submitDrafts(ids: string[]): Promise<{ success: boolean; failedIds?: string[]; error?: string }> {
|
||||
const failedIds: string[] = [];
|
||||
for (const id of ids) {
|
||||
const result = await submitDraft(id);
|
||||
if (!result.success) failedIds.push(id);
|
||||
}
|
||||
if (failedIds.length > 0) {
|
||||
return { success: false, failedIds, error: `${failedIds.length}건의 상신에 실패했습니다.` };
|
||||
}
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function cancelDraft(id: string): Promise<ActionResult> {
|
||||
return executeServerAction({
|
||||
url: buildApiUrl(`/api/v1/approvals/${id}/cancel`),
|
||||
method: 'POST',
|
||||
body: {},
|
||||
errorMessage: '결재 회수에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
754
src/components/approval_backup_v1/DraftBox/index.tsx
Normal file
754
src/components/approval_backup_v1/DraftBox/index.tsx
Normal file
@@ -0,0 +1,754 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useMemo, useCallback, useEffect, useTransition, useRef } from 'react';
|
||||
import { invalidateDashboard } from '@/lib/dashboard-invalidation';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useDateRange } from '@/hooks';
|
||||
import {
|
||||
FileText,
|
||||
Send,
|
||||
Trash2,
|
||||
Plus,
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
getDrafts,
|
||||
getDraftsSummary,
|
||||
getDraftById,
|
||||
deleteDraft,
|
||||
deleteDrafts,
|
||||
submitDraft,
|
||||
submitDrafts,
|
||||
} from './actions';
|
||||
import { sendApprovalNotification } from '@/lib/actions/fcm';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { TableRow, TableCell } from '@/components/ui/table';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import {
|
||||
UniversalListPage,
|
||||
type UniversalListConfig,
|
||||
} from '@/components/templates/UniversalListPage';
|
||||
import { ListMobileCard, InfoField } from '@/components/organisms/MobileCard';
|
||||
// import { DocumentDetailModal } from '@/components/approval/DocumentDetail';
|
||||
import { DocumentDetailModalV2 as DocumentDetailModal } from '@/components/approval/DocumentDetail';
|
||||
import type {
|
||||
DocumentType,
|
||||
DocumentStatus as ModalDocumentStatus,
|
||||
ProposalDocumentData,
|
||||
ExpenseReportDocumentData,
|
||||
ExpenseEstimateDocumentData,
|
||||
} from '@/components/approval/DocumentDetail/types';
|
||||
import type {
|
||||
DraftRecord,
|
||||
Approver,
|
||||
SortOption,
|
||||
FilterOption,
|
||||
} from './types';
|
||||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||
import {
|
||||
SORT_OPTIONS,
|
||||
FILTER_OPTIONS,
|
||||
DOCUMENT_STATUS_LABELS,
|
||||
DOCUMENT_STATUS_COLORS,
|
||||
} from './types';
|
||||
|
||||
// ===== 통계 타입 =====
|
||||
interface DraftsSummary {
|
||||
total: number;
|
||||
draft: number;
|
||||
pending: number;
|
||||
approved: number;
|
||||
rejected: number;
|
||||
}
|
||||
|
||||
export function DraftBox() {
|
||||
const router = useRouter();
|
||||
const [, startTransition] = useTransition();
|
||||
|
||||
// ===== 상태 관리 =====
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [filterOption, setFilterOption] = useState<FilterOption>('all');
|
||||
const [sortOption, setSortOption] = useState<SortOption>('latest');
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const itemsPerPage = 20;
|
||||
|
||||
// 날짜 범위 상태
|
||||
const { startDate, endDate, setStartDate, setEndDate } = useDateRange('currentYear');
|
||||
|
||||
// API 데이터
|
||||
const [data, setData] = useState<DraftRecord[]>([]);
|
||||
const [totalCount, setTotalCount] = useState(0);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const isInitialLoadDone = useRef(false);
|
||||
|
||||
// 통계 데이터
|
||||
const [summary, setSummary] = useState<DraftsSummary | null>(null);
|
||||
|
||||
// ===== 문서 상세 모달 상태 =====
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [selectedDocument, setSelectedDocument] = useState<DraftRecord | null>(null);
|
||||
|
||||
// ===== 데이터 로드 =====
|
||||
const loadData = useCallback(async () => {
|
||||
if (!isInitialLoadDone.current) {
|
||||
setIsLoading(true);
|
||||
}
|
||||
try {
|
||||
const sortConfig: { sort_by: string; sort_dir: 'asc' | 'desc' } = (() => {
|
||||
switch (sortOption) {
|
||||
case 'latest':
|
||||
return { sort_by: 'created_at', sort_dir: 'desc' };
|
||||
case 'oldest':
|
||||
return { sort_by: 'created_at', sort_dir: 'asc' };
|
||||
case 'titleAsc':
|
||||
return { sort_by: 'title', sort_dir: 'asc' };
|
||||
case 'titleDesc':
|
||||
return { sort_by: 'title', sort_dir: 'desc' };
|
||||
default:
|
||||
return { sort_by: 'created_at', sort_dir: 'desc' };
|
||||
}
|
||||
})();
|
||||
|
||||
const result = await getDrafts({
|
||||
page: currentPage,
|
||||
per_page: itemsPerPage,
|
||||
search: searchQuery || undefined,
|
||||
status: filterOption !== 'all' ? filterOption : undefined,
|
||||
...sortConfig,
|
||||
});
|
||||
|
||||
setData(result.data);
|
||||
setTotalCount(result.total);
|
||||
setTotalPages(result.lastPage);
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('Failed to load drafts:', error);
|
||||
toast.error('기안함 목록을 불러오는데 실패했습니다.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
isInitialLoadDone.current = true;
|
||||
}
|
||||
}, [currentPage, itemsPerPage, searchQuery, filterOption, sortOption]);
|
||||
|
||||
// ===== 통계 로드 =====
|
||||
const loadSummary = useCallback(async () => {
|
||||
try {
|
||||
const result = await getDraftsSummary();
|
||||
setSummary(result);
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('Failed to load summary:', error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// ===== 초기 로드 및 필터 변경 시 데이터 재로드 =====
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [loadData]);
|
||||
|
||||
useEffect(() => {
|
||||
loadSummary();
|
||||
}, [loadSummary]);
|
||||
|
||||
// ===== 검색어 변경 시 페이지 초기화 =====
|
||||
useEffect(() => {
|
||||
setCurrentPage(1);
|
||||
}, [searchQuery, filterOption, sortOption]);
|
||||
|
||||
// ===== 액션 핸들러 =====
|
||||
const handleSubmit = useCallback(
|
||||
async (selectedItems: Set<string>) => {
|
||||
const ids = Array.from(selectedItems);
|
||||
if (ids.length === 0) return;
|
||||
|
||||
startTransition(async () => {
|
||||
try {
|
||||
const result = await submitDrafts(ids);
|
||||
if (result.success) {
|
||||
invalidateDashboard('approval');
|
||||
toast.success(`${ids.length}건의 문서를 상신했습니다.`);
|
||||
loadData();
|
||||
loadSummary();
|
||||
} else {
|
||||
toast.error(result.error || '상신에 실패했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('Submit error:', error);
|
||||
toast.error('상신 중 오류가 발생했습니다.');
|
||||
}
|
||||
});
|
||||
},
|
||||
[loadData, loadSummary]
|
||||
);
|
||||
|
||||
const handleDelete = useCallback(
|
||||
async (selectedItems: Set<string>) => {
|
||||
const ids = Array.from(selectedItems);
|
||||
if (ids.length === 0) return;
|
||||
|
||||
startTransition(async () => {
|
||||
try {
|
||||
const result = await deleteDrafts(ids);
|
||||
if (result.success) {
|
||||
invalidateDashboard('approval');
|
||||
toast.success(`${ids.length}건의 문서를 삭제했습니다.`);
|
||||
loadData();
|
||||
loadSummary();
|
||||
} else {
|
||||
toast.error(result.error || '삭제에 실패했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('Delete error:', error);
|
||||
toast.error('삭제 중 오류가 발생했습니다.');
|
||||
}
|
||||
});
|
||||
},
|
||||
[loadData, loadSummary]
|
||||
);
|
||||
|
||||
const handleDeleteSingle = useCallback(
|
||||
async (id: string) => {
|
||||
startTransition(async () => {
|
||||
try {
|
||||
const result = await deleteDraft(id);
|
||||
if (result.success) {
|
||||
invalidateDashboard('approval');
|
||||
toast.success('문서를 삭제했습니다.');
|
||||
loadData();
|
||||
loadSummary();
|
||||
} else {
|
||||
toast.error(result.error || '삭제에 실패했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('Delete error:', error);
|
||||
toast.error('삭제 중 오류가 발생했습니다.');
|
||||
}
|
||||
});
|
||||
},
|
||||
[loadData, loadSummary]
|
||||
);
|
||||
|
||||
const handleNewDocument = useCallback(() => {
|
||||
router.push('/ko/approval/draft?mode=new');
|
||||
}, [router]);
|
||||
|
||||
const handleSendNotification = useCallback(async () => {
|
||||
startTransition(async () => {
|
||||
try {
|
||||
const result = await sendApprovalNotification();
|
||||
if (result.success) {
|
||||
toast.success(`결재 알림을 발송했습니다. (${result.sentCount || 0}건)`);
|
||||
} else {
|
||||
toast.error(result.error || '알림 발송에 실패했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('Notification error:', error);
|
||||
toast.error('알림 발송 중 오류가 발생했습니다.');
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
// ===== 문서 클릭 핸들러 =====
|
||||
const handleDocumentClick = useCallback(
|
||||
async (item: DraftRecord) => {
|
||||
if (item.status === 'draft') {
|
||||
router.push(`/ko/approval/draft/new?id=${item.id}&mode=edit`);
|
||||
} else {
|
||||
const detailData = await getDraftById(item.id);
|
||||
if (detailData) {
|
||||
setSelectedDocument(detailData);
|
||||
} else {
|
||||
setSelectedDocument(item);
|
||||
}
|
||||
setIsModalOpen(true);
|
||||
}
|
||||
},
|
||||
[router]
|
||||
);
|
||||
|
||||
const handleModalEdit = useCallback(() => {
|
||||
if (selectedDocument) {
|
||||
router.push(`/ko/approval/draft/new?id=${selectedDocument.id}&mode=edit`);
|
||||
setIsModalOpen(false);
|
||||
}
|
||||
}, [selectedDocument, router]);
|
||||
|
||||
const handleModalCopy = useCallback(() => {
|
||||
if (selectedDocument) {
|
||||
router.push(`/ko/approval/draft/new?copyFrom=${selectedDocument.id}`);
|
||||
setIsModalOpen(false);
|
||||
}
|
||||
}, [selectedDocument, router]);
|
||||
|
||||
const handleModalSubmit = useCallback(async () => {
|
||||
if (!selectedDocument) return;
|
||||
|
||||
startTransition(async () => {
|
||||
try {
|
||||
const result = await submitDraft(selectedDocument.id);
|
||||
if (result.success) {
|
||||
invalidateDashboard('approval');
|
||||
toast.success('문서를 상신했습니다.');
|
||||
setIsModalOpen(false);
|
||||
setSelectedDocument(null);
|
||||
loadData();
|
||||
loadSummary();
|
||||
} else {
|
||||
toast.error(result.error || '상신에 실패했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('Submit error:', error);
|
||||
toast.error('상신 중 오류가 발생했습니다.');
|
||||
}
|
||||
});
|
||||
}, [selectedDocument, loadData, loadSummary]);
|
||||
|
||||
// ===== 문서 타입 판별 =====
|
||||
const getDocumentType = (item: DraftRecord): DocumentType => {
|
||||
if (item.documentTypeCode) {
|
||||
if (item.documentTypeCode === 'expenseEstimate') return 'expenseEstimate';
|
||||
if (item.documentTypeCode === 'expenseReport') return 'expenseReport';
|
||||
if (item.documentTypeCode === 'proposal') return 'proposal';
|
||||
}
|
||||
if (item.documentType.includes('지출') && item.documentType.includes('예상'))
|
||||
return 'expenseEstimate';
|
||||
if (item.documentType.includes('지출')) return 'expenseReport';
|
||||
return 'proposal';
|
||||
};
|
||||
|
||||
// ===== 모달용 데이터 변환 =====
|
||||
const convertToModalData = (
|
||||
item: DraftRecord
|
||||
): ProposalDocumentData | ExpenseReportDocumentData | ExpenseEstimateDocumentData => {
|
||||
const docType = getDocumentType(item);
|
||||
const content = item.content || {};
|
||||
|
||||
const drafter = {
|
||||
id: 'drafter-1',
|
||||
name: item.drafter,
|
||||
position: item.drafterPosition || '',
|
||||
department: item.drafterDepartment || '',
|
||||
status: 'approved' as const,
|
||||
};
|
||||
const approvers = item.approvers.map((a) => ({
|
||||
id: a.id,
|
||||
name: a.name,
|
||||
position: a.position,
|
||||
department: a.department,
|
||||
status: a.status,
|
||||
approvedAt: a.approvedAt,
|
||||
}));
|
||||
|
||||
switch (docType) {
|
||||
case 'expenseEstimate': {
|
||||
const items =
|
||||
(content.items as Array<{
|
||||
id: string;
|
||||
checked?: boolean;
|
||||
expectedPaymentDate: string;
|
||||
category: string;
|
||||
amount: number;
|
||||
vendor: string;
|
||||
memo?: string;
|
||||
}>) || [];
|
||||
|
||||
return {
|
||||
documentNo: item.documentNo,
|
||||
createdAt: item.draftDate,
|
||||
items: items.map((i, idx) => ({
|
||||
id: i.id || String(idx + 1),
|
||||
expectedPaymentDate: i.expectedPaymentDate || '',
|
||||
category: i.category || '',
|
||||
amount: i.amount || 0,
|
||||
vendor: i.vendor || '',
|
||||
account: i.memo || '',
|
||||
})),
|
||||
totalExpense: (content.totalExpense as number) || 0,
|
||||
accountBalance: (content.accountBalance as number) || 0,
|
||||
finalDifference: (content.finalDifference as number) || 0,
|
||||
approvers,
|
||||
drafter,
|
||||
};
|
||||
}
|
||||
case 'expenseReport': {
|
||||
const items =
|
||||
(content.items as Array<{
|
||||
id: string;
|
||||
description: string;
|
||||
amount: number;
|
||||
note?: string;
|
||||
}>) || [];
|
||||
|
||||
return {
|
||||
documentNo: item.documentNo,
|
||||
createdAt: item.draftDate,
|
||||
requestDate: (content.requestDate as string) || item.draftDate,
|
||||
paymentDate: (content.paymentDate as string) || item.draftDate,
|
||||
items: items.map((i, idx) => ({
|
||||
id: i.id || String(idx + 1),
|
||||
no: idx + 1,
|
||||
description: i.description || '',
|
||||
amount: i.amount || 0,
|
||||
note: i.note || '',
|
||||
})),
|
||||
cardInfo: (content.cardId as string) || '',
|
||||
totalAmount: (content.totalAmount as number) || 0,
|
||||
attachments: [],
|
||||
approvers,
|
||||
drafter,
|
||||
};
|
||||
}
|
||||
default: {
|
||||
const files =
|
||||
(content.files as Array<{ id: number; name: string; url?: string }>) || [];
|
||||
const attachmentUrls = files.map((f) => `/api/proxy/files/${f.id}/download`);
|
||||
|
||||
return {
|
||||
documentNo: item.documentNo,
|
||||
createdAt: item.draftDate,
|
||||
vendor: (content.vendor as string) || '',
|
||||
vendorPaymentDate: (content.vendorPaymentDate as string) || '',
|
||||
title: (content.title as string) || item.title,
|
||||
description: (content.description as string) || '',
|
||||
reason: (content.reason as string) || '',
|
||||
estimatedCost: (content.estimatedCost as number) || 0,
|
||||
attachments: attachmentUrls,
|
||||
approvers,
|
||||
drafter,
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// ===== 결재자 텍스트 포맷 =====
|
||||
const formatApprovers = (approvers: Approver[]): string => {
|
||||
if (approvers.length === 0) return '-';
|
||||
if (approvers.length === 1) return approvers[0].name;
|
||||
return `${approvers[0].name} 외 ${approvers.length - 1}명`;
|
||||
};
|
||||
|
||||
// ===== UniversalListPage 설정 =====
|
||||
const draftBoxConfig: UniversalListConfig<DraftRecord> = useMemo(
|
||||
() => ({
|
||||
title: '기안함',
|
||||
description: '작성한 결재 문서를 관리합니다',
|
||||
icon: FileText,
|
||||
basePath: '/approval/draft',
|
||||
|
||||
idField: 'id',
|
||||
|
||||
actions: {
|
||||
getList: async () => ({
|
||||
success: true,
|
||||
data: data,
|
||||
totalCount: totalCount,
|
||||
totalPages: totalPages,
|
||||
}),
|
||||
},
|
||||
|
||||
columns: [
|
||||
{ key: 'no', label: '번호', className: 'w-[60px] text-center' },
|
||||
{ key: 'documentNo', label: '문서번호', copyable: true },
|
||||
{ key: 'documentType', label: '문서유형', copyable: true },
|
||||
{ key: 'title', label: '제목', copyable: true },
|
||||
{ key: 'approvers', label: '결재자', copyable: true },
|
||||
{ key: 'draftDate', label: '기안일시', copyable: true },
|
||||
{ key: 'status', label: '상태', className: 'text-center' },
|
||||
],
|
||||
|
||||
// 검색창 (공통 컴포넌트에서 자동 생성)
|
||||
hideSearch: true,
|
||||
searchValue: searchQuery,
|
||||
onSearchChange: setSearchQuery,
|
||||
|
||||
dateRangeSelector: {
|
||||
enabled: true,
|
||||
showPresets: false,
|
||||
startDate,
|
||||
endDate,
|
||||
onStartDateChange: setStartDate,
|
||||
onEndDateChange: setEndDate,
|
||||
},
|
||||
|
||||
createButton: {
|
||||
label: '문서 작성',
|
||||
icon: Plus,
|
||||
onClick: handleNewDocument,
|
||||
},
|
||||
|
||||
searchPlaceholder: '문서번호, 제목, 기안자 검색...',
|
||||
|
||||
itemsPerPage: itemsPerPage,
|
||||
|
||||
// 모바일 필터 설정
|
||||
filterConfig: [
|
||||
{
|
||||
key: 'status',
|
||||
label: '상태',
|
||||
type: 'single',
|
||||
options: FILTER_OPTIONS.filter((o) => o.value !== 'all'),
|
||||
},
|
||||
{
|
||||
key: 'sort',
|
||||
label: '정렬',
|
||||
type: 'single',
|
||||
options: SORT_OPTIONS,
|
||||
},
|
||||
],
|
||||
initialFilters: {
|
||||
status: filterOption,
|
||||
sort: sortOption,
|
||||
},
|
||||
filterTitle: '기안함 필터',
|
||||
|
||||
computeStats: () => {
|
||||
const inProgressCount = summary ? summary.pending : 0;
|
||||
const approvedCount = summary?.approved ?? 0;
|
||||
const rejectedCount = summary?.rejected ?? 0;
|
||||
const draftCount = summary?.draft ?? 0;
|
||||
|
||||
return [
|
||||
{
|
||||
label: '진행',
|
||||
value: `${inProgressCount}건`,
|
||||
icon: FileText,
|
||||
iconColor: 'text-blue-500',
|
||||
},
|
||||
{
|
||||
label: '완료',
|
||||
value: `${approvedCount}건`,
|
||||
icon: FileText,
|
||||
iconColor: 'text-green-500',
|
||||
},
|
||||
{
|
||||
label: '반려',
|
||||
value: `${rejectedCount}건`,
|
||||
icon: FileText,
|
||||
iconColor: 'text-red-500',
|
||||
},
|
||||
{
|
||||
label: '임시 저장',
|
||||
value: `${draftCount}건`,
|
||||
icon: FileText,
|
||||
iconColor: 'text-gray-500',
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
// headerActions: () => (
|
||||
// <div className="ml-auto flex gap-2">
|
||||
// <Button variant="outline" onClick={handleSendNotification}>
|
||||
// <Bell className="h-4 w-4 mr-2" />
|
||||
// 문서완료
|
||||
// </Button>
|
||||
// </div>
|
||||
// ),
|
||||
|
||||
selectionActions: ({ selectedItems, onClearSelection }) => (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="default"
|
||||
onClick={() => {
|
||||
handleSubmit(selectedItems);
|
||||
onClearSelection();
|
||||
}}
|
||||
>
|
||||
<Send className="h-4 w-4 mr-2" />
|
||||
상신
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={() => {
|
||||
handleDelete(selectedItems);
|
||||
onClearSelection();
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
삭제
|
||||
</Button>
|
||||
</>
|
||||
),
|
||||
|
||||
tableHeaderActions: (
|
||||
<div className="flex items-center gap-2">
|
||||
<Select
|
||||
value={filterOption}
|
||||
onValueChange={(value) => setFilterOption(value as FilterOption)}
|
||||
>
|
||||
<SelectTrigger className="min-w-[120px] w-auto">
|
||||
<SelectValue placeholder="필터 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{FILTER_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select
|
||||
value={sortOption}
|
||||
onValueChange={(value) => setSortOption(value as SortOption)}
|
||||
>
|
||||
<SelectTrigger className="min-w-[120px] w-auto">
|
||||
<SelectValue placeholder="정렬 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SORT_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
),
|
||||
|
||||
renderTableRow: (item, index, globalIndex, handlers) => {
|
||||
const { isSelected, onToggle } = handlers;
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
key={item.id}
|
||||
className="hover:bg-muted/50 cursor-pointer"
|
||||
onClick={() => handleDocumentClick(item)}
|
||||
>
|
||||
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
|
||||
<Checkbox checked={isSelected} onCheckedChange={onToggle} />
|
||||
</TableCell>
|
||||
<TableCell className="text-center">{globalIndex}</TableCell>
|
||||
<TableCell className="text-sm">{item.documentNo}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline">{item.documentType}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="font-medium max-w-[250px] truncate">
|
||||
{item.title}
|
||||
</TableCell>
|
||||
<TableCell>{formatApprovers(item.approvers)}</TableCell>
|
||||
<TableCell>{item.draftDate}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Badge className={DOCUMENT_STATUS_COLORS[item.status]}>
|
||||
{DOCUMENT_STATUS_LABELS[item.status]}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
},
|
||||
|
||||
renderMobileCard: (item, index, globalIndex, handlers) => {
|
||||
const { isSelected, onToggle } = handlers;
|
||||
|
||||
return (
|
||||
<ListMobileCard
|
||||
id={item.id}
|
||||
title={item.title}
|
||||
headerBadges={
|
||||
<>
|
||||
<Badge variant="outline">{item.documentType}</Badge>
|
||||
<Badge className={DOCUMENT_STATUS_COLORS[item.status]}>
|
||||
{DOCUMENT_STATUS_LABELS[item.status]}
|
||||
</Badge>
|
||||
</>
|
||||
}
|
||||
isSelected={isSelected}
|
||||
onToggleSelection={onToggle}
|
||||
infoGrid={
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<InfoField label="문서번호" value={item.documentNo} />
|
||||
<InfoField label="기안일자" value={item.draftDate} />
|
||||
<InfoField label="기안자" value={item.drafter} />
|
||||
<InfoField
|
||||
label="결재자"
|
||||
value={item.approvers.map((a) => a.name).join(' → ') || '-'}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
onClick={() => handleDocumentClick(item)}
|
||||
/>
|
||||
);
|
||||
},
|
||||
|
||||
renderDialogs: () =>
|
||||
selectedDocument && (
|
||||
<DocumentDetailModal
|
||||
open={isModalOpen}
|
||||
onOpenChange={setIsModalOpen}
|
||||
documentType={getDocumentType(selectedDocument)}
|
||||
data={convertToModalData(selectedDocument)}
|
||||
mode="draft"
|
||||
documentStatus={selectedDocument.status as ModalDocumentStatus}
|
||||
onEdit={handleModalEdit}
|
||||
onCopy={handleModalCopy}
|
||||
onSubmit={handleModalSubmit}
|
||||
/>
|
||||
),
|
||||
}),
|
||||
[
|
||||
data,
|
||||
totalCount,
|
||||
totalPages,
|
||||
startDate,
|
||||
endDate,
|
||||
handleNewDocument,
|
||||
summary,
|
||||
handleSubmit,
|
||||
handleDelete,
|
||||
handleSendNotification,
|
||||
filterOption,
|
||||
sortOption,
|
||||
handleDocumentClick,
|
||||
handleDeleteSingle,
|
||||
formatApprovers,
|
||||
selectedDocument,
|
||||
isModalOpen,
|
||||
handleModalEdit,
|
||||
handleModalCopy,
|
||||
handleModalSubmit,
|
||||
]
|
||||
);
|
||||
|
||||
// 모바일 필터 변경 핸들러
|
||||
const handleFilterChange = useCallback((filters: Record<string, string | string[]>) => {
|
||||
if (filters.status) {
|
||||
setFilterOption(filters.status as FilterOption);
|
||||
}
|
||||
if (filters.sort) {
|
||||
setSortOption(filters.sort as SortOption);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<UniversalListPage<DraftRecord>
|
||||
config={draftBoxConfig}
|
||||
initialData={data}
|
||||
initialTotalCount={totalCount}
|
||||
externalPagination={{
|
||||
currentPage,
|
||||
totalPages,
|
||||
totalItems: totalCount,
|
||||
itemsPerPage,
|
||||
onPageChange: setCurrentPage,
|
||||
}}
|
||||
onSearchChange={setSearchQuery}
|
||||
onFilterChange={handleFilterChange}
|
||||
externalIsLoading={isLoading}
|
||||
/>
|
||||
);
|
||||
}
|
||||
90
src/components/approval_backup_v1/DraftBox/types.ts
Normal file
90
src/components/approval_backup_v1/DraftBox/types.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* 기안함 타입 정의
|
||||
*/
|
||||
|
||||
// ===== 문서 상태 =====
|
||||
export type DocumentStatus = 'draft' | 'pending' | 'inProgress' | 'approved' | 'rejected';
|
||||
|
||||
// ===== 필터 옵션 =====
|
||||
export type FilterOption = 'all' | 'draft' | 'pending' | 'inProgress' | 'approved' | 'rejected';
|
||||
|
||||
export const FILTER_OPTIONS: { value: FilterOption; label: string }[] = [
|
||||
{ value: 'all', label: '전체' },
|
||||
{ value: 'draft', label: '임시저장' },
|
||||
{ value: 'pending', label: '결재대기' },
|
||||
{ value: 'inProgress', label: '진행중' },
|
||||
{ value: 'approved', label: '완료' },
|
||||
{ value: 'rejected', label: '반려' },
|
||||
];
|
||||
|
||||
// ===== 정렬 옵션 =====
|
||||
export type SortOption = 'latest' | 'oldest' | 'titleAsc' | 'titleDesc';
|
||||
|
||||
export const SORT_OPTIONS: { value: SortOption; label: string }[] = [
|
||||
{ value: 'latest', label: '최신순' },
|
||||
{ value: 'oldest', label: '오래된순' },
|
||||
{ value: 'titleAsc', label: '제목 오름차순' },
|
||||
{ value: 'titleDesc', label: '제목 내림차순' },
|
||||
];
|
||||
|
||||
// ===== 결재자 정보 =====
|
||||
export interface Approver {
|
||||
id: string;
|
||||
name: string;
|
||||
position: string;
|
||||
department: string;
|
||||
status: 'pending' | 'approved' | 'rejected' | 'none';
|
||||
approvedAt?: string;
|
||||
}
|
||||
|
||||
// ===== 기안 문서 레코드 =====
|
||||
export interface DraftRecord {
|
||||
id: string;
|
||||
documentNo: string; // 문서번호
|
||||
documentType: string; // 문서제목 (양식명)
|
||||
documentTypeCode: string; // 문서유형 코드 (proposal, expenseReport, expenseEstimate)
|
||||
title: string; // 제목
|
||||
draftDate: string; // 기안일자
|
||||
drafter: string; // 기안자
|
||||
drafterPosition?: string; // 기안자 직책
|
||||
drafterDepartment?: string;// 기안자 부서
|
||||
approvers: Approver[]; // 결재자 목록 (최대 3명)
|
||||
status: DocumentStatus; // 문서상태
|
||||
content?: Record<string, unknown>; // 문서 내용 (JSON)
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
// ===== 상수 정의 =====
|
||||
|
||||
export const DOCUMENT_STATUS_LABELS: Record<DocumentStatus, string> = {
|
||||
draft: '임시저장',
|
||||
pending: '결재대기',
|
||||
inProgress: '진행중',
|
||||
approved: '완료',
|
||||
rejected: '반려',
|
||||
};
|
||||
|
||||
export const DOCUMENT_STATUS_COLORS: Record<DocumentStatus, string> = {
|
||||
draft: 'bg-gray-100 text-gray-800',
|
||||
pending: 'bg-yellow-100 text-yellow-800',
|
||||
inProgress: 'bg-blue-100 text-blue-800',
|
||||
approved: 'bg-green-100 text-green-800',
|
||||
rejected: 'bg-red-100 text-red-800',
|
||||
};
|
||||
|
||||
export const APPROVER_STATUS_COLORS: Record<Approver['status'], string> = {
|
||||
none: 'bg-gray-100 text-gray-600',
|
||||
pending: 'bg-yellow-100 text-yellow-800',
|
||||
approved: 'bg-green-100 text-green-800',
|
||||
rejected: 'bg-red-100 text-red-800',
|
||||
};
|
||||
|
||||
// ===== 기안함 현황 통계 =====
|
||||
export interface DraftsSummary {
|
||||
total: number;
|
||||
draft: number;
|
||||
pending: number;
|
||||
approved: number;
|
||||
rejected: number;
|
||||
}
|
||||
170
src/components/approval_backup_v1/ReferenceBox/actions.ts
Normal file
170
src/components/approval_backup_v1/ReferenceBox/actions.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* 참조함 서버 액션
|
||||
*
|
||||
* API Endpoints:
|
||||
* - GET /api/v1/approvals/reference - 참조함 목록 조회
|
||||
* - POST /api/v1/approvals/{id}/read - 열람 처리
|
||||
* - POST /api/v1/approvals/{id}/unread - 미열람 처리
|
||||
*/
|
||||
|
||||
'use server';
|
||||
|
||||
|
||||
import { executeServerAction, type ActionResult } from '@/lib/api/execute-server-action';
|
||||
import { buildApiUrl } from '@/lib/api/query-params';
|
||||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||
import type { PaginatedApiResponse } from '@/lib/api/types';
|
||||
import type { ReferenceRecord, ApprovalType, DocumentStatus } from './types';
|
||||
|
||||
// ============================================
|
||||
// API 응답 타입 정의
|
||||
// ============================================
|
||||
|
||||
interface ReferenceApiData {
|
||||
id: number;
|
||||
document_number: string;
|
||||
title: string;
|
||||
status: string;
|
||||
form?: { id: number; name: string; code: string; category: string };
|
||||
drafter?: { id: number; name: string; position?: string; department?: { name: string } };
|
||||
steps?: ReferenceStepApiData[];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
interface ReferenceStepApiData {
|
||||
id: number;
|
||||
step_order: number;
|
||||
step_type: string;
|
||||
approver_id: number;
|
||||
approver?: { id: number; name: string; position?: string; department?: { name: string } };
|
||||
is_read: boolean;
|
||||
read_at?: string;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 헬퍼 함수
|
||||
// ============================================
|
||||
|
||||
function mapApiStatus(apiStatus: string): DocumentStatus {
|
||||
const statusMap: Record<string, DocumentStatus> = {
|
||||
'draft': 'pending', 'pending': 'pending', 'in_progress': 'pending',
|
||||
'approved': 'approved', 'rejected': 'rejected',
|
||||
};
|
||||
return statusMap[apiStatus] || 'pending';
|
||||
}
|
||||
|
||||
function mapApprovalType(formCategory?: string): ApprovalType {
|
||||
const typeMap: Record<string, ApprovalType> = {
|
||||
'expense_report': 'expense_report', 'proposal': 'proposal', 'expense_estimate': 'expense_estimate',
|
||||
};
|
||||
return typeMap[formCategory || ''] || 'proposal';
|
||||
}
|
||||
|
||||
function transformApiToFrontend(data: ReferenceApiData): ReferenceRecord {
|
||||
const referenceStep = data.steps?.find(s => s.step_type === 'reference');
|
||||
const isRead = referenceStep?.is_read ?? false;
|
||||
const readAt = referenceStep?.read_at;
|
||||
|
||||
return {
|
||||
id: String(data.id),
|
||||
documentNo: data.document_number,
|
||||
approvalType: mapApprovalType(data.form?.category),
|
||||
title: data.title,
|
||||
draftDate: data.created_at.replace('T', ' ').substring(0, 16),
|
||||
drafter: data.drafter?.name || '',
|
||||
drafterDepartment: data.drafter?.department?.name || '',
|
||||
drafterPosition: data.drafter?.position || '',
|
||||
documentStatus: mapApiStatus(data.status),
|
||||
readStatus: isRead ? 'read' : 'unread',
|
||||
readAt: readAt ? readAt.replace('T', ' ').substring(0, 16) : undefined,
|
||||
createdAt: data.created_at,
|
||||
updatedAt: data.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// API 함수
|
||||
// ============================================
|
||||
|
||||
export async function getReferences(params?: {
|
||||
page?: number; per_page?: number; search?: string; is_read?: boolean;
|
||||
approval_type?: string; sort_by?: string; sort_dir?: 'asc' | 'desc';
|
||||
}): Promise<{ data: ReferenceRecord[]; total: number; lastPage: number }> {
|
||||
const result = await executeServerAction<PaginatedApiResponse<ReferenceApiData>>({
|
||||
url: buildApiUrl('/api/v1/approvals/reference', {
|
||||
page: params?.page,
|
||||
per_page: params?.per_page,
|
||||
search: params?.search,
|
||||
is_read: params?.is_read !== undefined ? (params.is_read ? '1' : '0') : undefined,
|
||||
approval_type: params?.approval_type !== 'all' ? params?.approval_type : undefined,
|
||||
sort_by: params?.sort_by,
|
||||
sort_dir: params?.sort_dir,
|
||||
}),
|
||||
errorMessage: '참조 목록 조회에 실패했습니다.',
|
||||
});
|
||||
|
||||
if (!result.success || !result.data?.data) {
|
||||
return { data: [], total: 0, lastPage: 1 };
|
||||
}
|
||||
|
||||
return {
|
||||
data: result.data.data.map(transformApiToFrontend),
|
||||
total: result.data.total,
|
||||
lastPage: result.data.last_page,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getReferenceSummary(): Promise<{ all: number; read: number; unread: number } | null> {
|
||||
try {
|
||||
const allResult = await getReferences({ per_page: 1 });
|
||||
const readResult = await getReferences({ per_page: 1, is_read: true });
|
||||
const unreadResult = await getReferences({ per_page: 1, is_read: false });
|
||||
return { all: allResult.total, read: readResult.total, unread: unreadResult.total };
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function markAsRead(id: string): Promise<ActionResult> {
|
||||
return executeServerAction({
|
||||
url: buildApiUrl(`/api/v1/approvals/${id}/read`),
|
||||
method: 'POST',
|
||||
body: {},
|
||||
errorMessage: '열람 처리에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
export async function markAsUnread(id: string): Promise<ActionResult> {
|
||||
return executeServerAction({
|
||||
url: buildApiUrl(`/api/v1/approvals/${id}/unread`),
|
||||
method: 'POST',
|
||||
body: {},
|
||||
errorMessage: '미열람 처리에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
export async function markAsReadBulk(ids: string[]): Promise<{ success: boolean; failedIds?: string[]; error?: string }> {
|
||||
const failedIds: string[] = [];
|
||||
for (const id of ids) {
|
||||
const result = await markAsRead(id);
|
||||
if (!result.success) failedIds.push(id);
|
||||
}
|
||||
if (failedIds.length > 0) {
|
||||
return { success: false, failedIds, error: `${failedIds.length}건의 열람 처리에 실패했습니다.` };
|
||||
}
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function markAsUnreadBulk(ids: string[]): Promise<{ success: boolean; failedIds?: string[]; error?: string }> {
|
||||
const failedIds: string[] = [];
|
||||
for (const id of ids) {
|
||||
const result = await markAsUnread(id);
|
||||
if (!result.success) failedIds.push(id);
|
||||
}
|
||||
if (failedIds.length > 0) {
|
||||
return { success: false, failedIds, error: `${failedIds.length}건의 미열람 처리에 실패했습니다.` };
|
||||
}
|
||||
return { success: true };
|
||||
}
|
||||
692
src/components/approval_backup_v1/ReferenceBox/index.tsx
Normal file
692
src/components/approval_backup_v1/ReferenceBox/index.tsx
Normal file
@@ -0,0 +1,692 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useMemo, useCallback, useEffect, useTransition, useRef } from 'react';
|
||||
import { useDateRange } from '@/hooks';
|
||||
import {
|
||||
Files,
|
||||
Eye,
|
||||
EyeOff,
|
||||
BookOpen,
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
getReferences,
|
||||
getReferenceSummary,
|
||||
markAsReadBulk,
|
||||
markAsUnreadBulk,
|
||||
} from './actions';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { TableRow, TableCell } from '@/components/ui/table';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
|
||||
import {
|
||||
UniversalListPage,
|
||||
type UniversalListConfig,
|
||||
type StatCard,
|
||||
type TabOption,
|
||||
} from '@/components/templates/UniversalListPage';
|
||||
import { ListMobileCard, InfoField } from '@/components/organisms/MobileCard';
|
||||
// import { DocumentDetailModal } from '@/components/approval/DocumentDetail';
|
||||
import { DocumentDetailModalV2 as DocumentDetailModal } from '@/components/approval/DocumentDetail';
|
||||
import type { DocumentType, ProposalDocumentData, ExpenseReportDocumentData, ExpenseEstimateDocumentData } from '@/components/approval/DocumentDetail/types';
|
||||
import type {
|
||||
ReferenceTabType,
|
||||
ReferenceRecord,
|
||||
SortOption,
|
||||
FilterOption,
|
||||
ApprovalType,
|
||||
} from './types';
|
||||
import {
|
||||
REFERENCE_TAB_LABELS,
|
||||
SORT_OPTIONS,
|
||||
FILTER_OPTIONS,
|
||||
APPROVAL_TYPE_LABELS,
|
||||
READ_STATUS_LABELS,
|
||||
READ_STATUS_COLORS,
|
||||
} from './types';
|
||||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||
|
||||
// ===== 통계 타입 =====
|
||||
interface ReferenceSummary {
|
||||
all: number;
|
||||
read: number;
|
||||
unread: number;
|
||||
}
|
||||
|
||||
export function ReferenceBox() {
|
||||
const [, startTransition] = useTransition();
|
||||
|
||||
// ===== 상태 관리 =====
|
||||
const [activeTab, setActiveTab] = useState<ReferenceTabType>('all');
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [filterOption, setFilterOption] = useState<FilterOption>('all');
|
||||
const [sortOption, setSortOption] = useState<SortOption>('latest');
|
||||
const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set());
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const itemsPerPage = 20;
|
||||
|
||||
// 날짜 범위 상태
|
||||
const { startDate, endDate, setStartDate, setEndDate } = useDateRange('currentYear');
|
||||
|
||||
// 다이얼로그 상태
|
||||
const [markReadDialogOpen, setMarkReadDialogOpen] = useState(false);
|
||||
const [markUnreadDialogOpen, setMarkUnreadDialogOpen] = useState(false);
|
||||
|
||||
// ===== 문서 상세 모달 상태 =====
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [selectedDocument, setSelectedDocument] = useState<ReferenceRecord | null>(null);
|
||||
|
||||
// API 데이터
|
||||
const [data, setData] = useState<ReferenceRecord[]>([]);
|
||||
const [totalCount, setTotalCount] = useState(0);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const isInitialLoadDone = useRef(false);
|
||||
|
||||
// 통계 데이터
|
||||
const [summary, setSummary] = useState<ReferenceSummary | null>(null);
|
||||
|
||||
// ===== 데이터 로드 =====
|
||||
const loadData = useCallback(async () => {
|
||||
if (!isInitialLoadDone.current) {
|
||||
setIsLoading(true);
|
||||
}
|
||||
try {
|
||||
// 정렬 옵션 변환
|
||||
const sortConfig: { sort_by: string; sort_dir: 'asc' | 'desc' } = (() => {
|
||||
switch (sortOption) {
|
||||
case 'latest': return { sort_by: 'created_at', sort_dir: 'desc' };
|
||||
case 'oldest': return { sort_by: 'created_at', sort_dir: 'asc' };
|
||||
case 'draftDateAsc': return { sort_by: 'created_at', sort_dir: 'asc' };
|
||||
case 'draftDateDesc': return { sort_by: 'created_at', sort_dir: 'desc' };
|
||||
default: return { sort_by: 'created_at', sort_dir: 'desc' };
|
||||
}
|
||||
})();
|
||||
|
||||
// 탭에 따른 is_read 파라미터
|
||||
const isReadParam = activeTab === 'all' ? undefined : activeTab === 'read';
|
||||
|
||||
const result = await getReferences({
|
||||
page: currentPage,
|
||||
per_page: itemsPerPage,
|
||||
search: searchQuery || undefined,
|
||||
is_read: isReadParam,
|
||||
approval_type: filterOption !== 'all' ? filterOption : undefined,
|
||||
...sortConfig,
|
||||
});
|
||||
|
||||
setData(result.data);
|
||||
setTotalCount(result.total);
|
||||
setTotalPages(result.lastPage);
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('Failed to load references:', error);
|
||||
toast.error('참조함 목록을 불러오는데 실패했습니다.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
isInitialLoadDone.current = true;
|
||||
}
|
||||
}, [currentPage, itemsPerPage, searchQuery, filterOption, sortOption, activeTab]);
|
||||
|
||||
// ===== 통계 로드 =====
|
||||
const loadSummary = useCallback(async () => {
|
||||
try {
|
||||
const result = await getReferenceSummary();
|
||||
setSummary(result);
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('Failed to load summary:', error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// ===== 초기 로드 =====
|
||||
// 마운트 시 1회만 실행 (summary 로드)
|
||||
useEffect(() => {
|
||||
loadSummary();
|
||||
|
||||
}, []);
|
||||
|
||||
// ===== 데이터 로드 (의존성 명시적 관리) =====
|
||||
// currentPage, searchQuery, filterOption, sortOption, activeTab 변경 시 데이터 재로드
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
|
||||
}, [currentPage, searchQuery, filterOption, sortOption, activeTab]);
|
||||
|
||||
// ===== 검색어/필터/탭 변경 시 페이지 초기화 =====
|
||||
// ref로 이전 값 추적하여 불필요한 상태 변경 방지 (무한 루프 방지)
|
||||
const prevSearchRef = useRef(searchQuery);
|
||||
const prevFilterRef = useRef(filterOption);
|
||||
const prevSortRef = useRef(sortOption);
|
||||
const prevTabRef = useRef(activeTab);
|
||||
|
||||
useEffect(() => {
|
||||
const searchChanged = prevSearchRef.current !== searchQuery;
|
||||
const filterChanged = prevFilterRef.current !== filterOption;
|
||||
const sortChanged = prevSortRef.current !== sortOption;
|
||||
const tabChanged = prevTabRef.current !== activeTab;
|
||||
|
||||
if (searchChanged || filterChanged || sortChanged || tabChanged) {
|
||||
// 페이지가 1이 아닐 때만 리셋 (불필요한 상태 변경 방지)
|
||||
if (currentPage !== 1) {
|
||||
setCurrentPage(1);
|
||||
}
|
||||
prevSearchRef.current = searchQuery;
|
||||
prevFilterRef.current = filterOption;
|
||||
prevSortRef.current = sortOption;
|
||||
prevTabRef.current = activeTab;
|
||||
}
|
||||
}, [searchQuery, filterOption, sortOption, activeTab, currentPage]);
|
||||
|
||||
// ===== 탭 변경 핸들러 =====
|
||||
const handleTabChange = useCallback((value: string) => {
|
||||
setActiveTab(value as ReferenceTabType);
|
||||
setSelectedItems(new Set());
|
||||
setSearchQuery('');
|
||||
}, []);
|
||||
|
||||
// ===== 체크박스 핸들러 =====
|
||||
const toggleSelection = useCallback((id: string) => {
|
||||
setSelectedItems(prev => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(id)) newSet.delete(id);
|
||||
else newSet.add(id);
|
||||
return newSet;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const toggleSelectAll = useCallback(() => {
|
||||
if (selectedItems.size === data.length && data.length > 0) {
|
||||
setSelectedItems(new Set());
|
||||
} else {
|
||||
setSelectedItems(new Set(data.map(item => item.id)));
|
||||
}
|
||||
}, [selectedItems.size, data]);
|
||||
|
||||
// ===== 통계 데이터 (API summary 사용) =====
|
||||
const stats = useMemo(() => {
|
||||
return {
|
||||
all: summary?.all ?? 0,
|
||||
read: summary?.read ?? 0,
|
||||
unread: summary?.unread ?? 0,
|
||||
};
|
||||
}, [summary]);
|
||||
|
||||
// ===== 열람/미열람 처리 핸들러 =====
|
||||
const handleMarkReadClick = useCallback(() => {
|
||||
if (selectedItems.size === 0) return;
|
||||
setMarkReadDialogOpen(true);
|
||||
}, [selectedItems.size]);
|
||||
|
||||
const handleMarkReadConfirm = useCallback(async () => {
|
||||
const ids = Array.from(selectedItems);
|
||||
|
||||
startTransition(async () => {
|
||||
try {
|
||||
const result = await markAsReadBulk(ids);
|
||||
if (result.success) {
|
||||
toast.success('열람 처리 완료', {
|
||||
description: '열람 처리가 완료되었습니다.',
|
||||
});
|
||||
setSelectedItems(new Set());
|
||||
loadData();
|
||||
loadSummary();
|
||||
} else {
|
||||
toast.error(result.error || '열람 처리에 실패했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('Mark read error:', error);
|
||||
toast.error('열람 처리 중 오류가 발생했습니다.');
|
||||
}
|
||||
});
|
||||
|
||||
setMarkReadDialogOpen(false);
|
||||
}, [selectedItems, loadData, loadSummary]);
|
||||
|
||||
const handleMarkUnreadClick = useCallback(() => {
|
||||
if (selectedItems.size === 0) return;
|
||||
setMarkUnreadDialogOpen(true);
|
||||
}, [selectedItems.size]);
|
||||
|
||||
const handleMarkUnreadConfirm = useCallback(async () => {
|
||||
const ids = Array.from(selectedItems);
|
||||
|
||||
startTransition(async () => {
|
||||
try {
|
||||
const result = await markAsUnreadBulk(ids);
|
||||
if (result.success) {
|
||||
toast.success('미열람 처리 완료', {
|
||||
description: '미열람 처리가 완료되었습니다.',
|
||||
});
|
||||
setSelectedItems(new Set());
|
||||
loadData();
|
||||
loadSummary();
|
||||
} else {
|
||||
toast.error(result.error || '미열람 처리에 실패했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('Mark unread error:', error);
|
||||
toast.error('미열람 처리 중 오류가 발생했습니다.');
|
||||
}
|
||||
});
|
||||
|
||||
setMarkUnreadDialogOpen(false);
|
||||
}, [selectedItems, loadData, loadSummary]);
|
||||
|
||||
// ===== 문서 클릭/상세 보기 핸들러 =====
|
||||
const handleDocumentClick = useCallback((item: ReferenceRecord) => {
|
||||
setSelectedDocument(item);
|
||||
setIsModalOpen(true);
|
||||
}, []);
|
||||
|
||||
// ===== ApprovalType → DocumentType 변환 =====
|
||||
const getDocumentType = (approvalType: ApprovalType): DocumentType => {
|
||||
switch (approvalType) {
|
||||
case 'expense_estimate': return 'expenseEstimate';
|
||||
case 'expense_report': return 'expenseReport';
|
||||
default: return 'proposal';
|
||||
}
|
||||
};
|
||||
|
||||
// ===== ReferenceRecord → 모달용 데이터 변환 =====
|
||||
const convertToModalData = (item: ReferenceRecord): ProposalDocumentData | ExpenseReportDocumentData | ExpenseEstimateDocumentData => {
|
||||
const docType = getDocumentType(item.approvalType);
|
||||
const drafter = {
|
||||
id: 'drafter-1',
|
||||
name: item.drafter,
|
||||
position: item.drafterPosition,
|
||||
department: item.drafterDepartment,
|
||||
status: 'approved' as const,
|
||||
};
|
||||
const approvers = [{
|
||||
id: 'approver-1',
|
||||
name: '결재자',
|
||||
position: '부장',
|
||||
department: '경영지원팀',
|
||||
status: 'approved' as const,
|
||||
}];
|
||||
|
||||
switch (docType) {
|
||||
case 'expenseEstimate':
|
||||
return {
|
||||
documentNo: item.documentNo,
|
||||
createdAt: item.draftDate,
|
||||
items: [
|
||||
{ id: '1', expectedPaymentDate: '2025-11-05', category: '통신 서비스', amount: 550000, vendor: 'KT', account: '국민 123-456-789012 홍길동' },
|
||||
{ id: '2', expectedPaymentDate: '2025-11-12', category: '인건 대행', amount: 2500000, vendor: '에이치알코리아', account: '신한 110-123-456789 (주)에이치알' },
|
||||
],
|
||||
totalExpense: 3050000,
|
||||
accountBalance: 25000000,
|
||||
finalDifference: 21950000,
|
||||
approvers,
|
||||
drafter,
|
||||
};
|
||||
case 'expenseReport':
|
||||
return {
|
||||
documentNo: item.documentNo,
|
||||
createdAt: item.draftDate,
|
||||
requestDate: item.draftDate,
|
||||
paymentDate: item.draftDate,
|
||||
items: [
|
||||
{ id: '1', no: 1, description: '업무용 택시비', amount: 50000, note: '고객사 미팅' },
|
||||
{ id: '2', no: 2, description: '식대', amount: 30000, note: '팀 회식' },
|
||||
],
|
||||
cardInfo: '삼성카드 **** 1234',
|
||||
totalAmount: 80000,
|
||||
attachments: [],
|
||||
approvers,
|
||||
drafter,
|
||||
};
|
||||
default:
|
||||
return {
|
||||
documentNo: item.documentNo,
|
||||
createdAt: item.draftDate,
|
||||
vendor: '거래처',
|
||||
vendorPaymentDate: item.draftDate,
|
||||
title: item.title,
|
||||
description: item.title,
|
||||
reason: '업무상 필요',
|
||||
estimatedCost: 1000000,
|
||||
attachments: [],
|
||||
approvers,
|
||||
drafter,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// ===== 통계 카드 =====
|
||||
const statCards: StatCard[] = useMemo(() => [
|
||||
{ label: '전체', value: `${stats.all}건`, icon: Files, iconColor: 'text-blue-500' },
|
||||
{ label: '열람', value: `${stats.read}건`, icon: Eye, iconColor: 'text-green-500' },
|
||||
{ label: '미열람', value: `${stats.unread}건`, icon: EyeOff, iconColor: 'text-red-500' },
|
||||
], [stats]);
|
||||
|
||||
// ===== 탭 옵션 (열람/미열람 토글 버튼 형태) =====
|
||||
const tabs: TabOption[] = useMemo(() => [
|
||||
{ value: 'all', label: REFERENCE_TAB_LABELS.all, count: stats.all, color: 'blue' },
|
||||
{ value: 'read', label: REFERENCE_TAB_LABELS.read, count: stats.read, color: 'green' },
|
||||
{ value: 'unread', label: REFERENCE_TAB_LABELS.unread, count: stats.unread, color: 'red' },
|
||||
], [stats]);
|
||||
|
||||
// ===== 테이블 컬럼 =====
|
||||
// 문서번호, 문서유형, 제목, 기안자, 기안일시, 상태
|
||||
const tableColumns = useMemo(() => [
|
||||
{ key: 'no', label: '번호', className: 'w-[60px] text-center' },
|
||||
{ key: 'documentNo', label: '문서번호', copyable: true },
|
||||
{ key: 'approvalType', label: '문서유형', copyable: true },
|
||||
{ key: 'title', label: '제목', copyable: true },
|
||||
{ key: 'drafter', label: '기안자', copyable: true },
|
||||
{ key: 'draftDate', label: '기안일시', copyable: true },
|
||||
{ key: 'status', label: '상태', className: 'text-center' },
|
||||
], []);
|
||||
|
||||
// ===== 테이블 헤더 액션 (필터 + 정렬 셀렉트) =====
|
||||
const tableHeaderActions = useMemo(() => (
|
||||
<div className="flex items-center gap-2">
|
||||
{/* 필터 셀렉트박스 */}
|
||||
<Select value={filterOption} onValueChange={(value) => setFilterOption(value as FilterOption)}>
|
||||
<SelectTrigger className="min-w-[140px] w-auto">
|
||||
<SelectValue placeholder="필터 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{FILTER_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* 정렬 셀렉트박스 */}
|
||||
<Select value={sortOption} onValueChange={(value) => setSortOption(value as SortOption)}>
|
||||
<SelectTrigger className="min-w-[140px] w-auto">
|
||||
<SelectValue placeholder="정렬 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SORT_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
), [filterOption, sortOption]);
|
||||
|
||||
// ===== UniversalListPage 설정 =====
|
||||
const referenceBoxConfig: UniversalListConfig<ReferenceRecord> = useMemo(() => ({
|
||||
title: '참조함',
|
||||
description: '참조로 지정된 문서를 확인합니다.',
|
||||
icon: BookOpen,
|
||||
basePath: '/approval/reference',
|
||||
|
||||
idField: 'id',
|
||||
|
||||
actions: {
|
||||
getList: async () => ({
|
||||
success: true,
|
||||
data: data,
|
||||
totalCount: totalCount,
|
||||
totalPages: totalPages,
|
||||
}),
|
||||
},
|
||||
|
||||
columns: tableColumns,
|
||||
|
||||
tabs: tabs,
|
||||
defaultTab: activeTab,
|
||||
|
||||
computeStats: () => statCards,
|
||||
|
||||
// 검색창 (공통 컴포넌트에서 자동 생성)
|
||||
hideSearch: true,
|
||||
searchValue: searchQuery,
|
||||
onSearchChange: setSearchQuery,
|
||||
|
||||
dateRangeSelector: {
|
||||
enabled: true,
|
||||
showPresets: false,
|
||||
startDate,
|
||||
endDate,
|
||||
onStartDateChange: setStartDate,
|
||||
onEndDateChange: setEndDate,
|
||||
},
|
||||
|
||||
searchPlaceholder: '제목, 기안자, 부서 검색...',
|
||||
searchFilter: (item: ReferenceRecord, search: string) => {
|
||||
const s = search.toLowerCase();
|
||||
return (
|
||||
item.title?.toLowerCase().includes(s) ||
|
||||
item.drafter?.toLowerCase().includes(s) ||
|
||||
item.drafterDepartment?.toLowerCase().includes(s) ||
|
||||
false
|
||||
);
|
||||
},
|
||||
|
||||
itemsPerPage: itemsPerPage,
|
||||
|
||||
tableHeaderActions: tableHeaderActions,
|
||||
|
||||
// 모바일 필터 설정
|
||||
filterConfig: [
|
||||
{
|
||||
key: 'approvalType',
|
||||
label: '문서유형',
|
||||
type: 'single',
|
||||
options: FILTER_OPTIONS.filter((o) => o.value !== 'all'),
|
||||
},
|
||||
{
|
||||
key: 'sort',
|
||||
label: '정렬',
|
||||
type: 'single',
|
||||
options: SORT_OPTIONS,
|
||||
},
|
||||
],
|
||||
initialFilters: {
|
||||
approvalType: filterOption,
|
||||
sort: sortOption,
|
||||
},
|
||||
filterTitle: '참조함 필터',
|
||||
|
||||
selectionActions: () => (
|
||||
<>
|
||||
<Button size="sm" variant="default" onClick={handleMarkReadClick}>
|
||||
<Eye className="h-4 w-4 mr-2" />
|
||||
열람
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={handleMarkUnreadClick}>
|
||||
<EyeOff className="h-4 w-4 mr-2" />
|
||||
미열람
|
||||
</Button>
|
||||
</>
|
||||
),
|
||||
|
||||
renderTableRow: (item, index, globalIndex, handlers) => {
|
||||
const { isSelected, onToggle, onRowClick: _onRowClick } = handlers;
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
key={item.id}
|
||||
className="hover:bg-muted/50 cursor-pointer"
|
||||
onClick={() => handleDocumentClick(item)}
|
||||
>
|
||||
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
|
||||
<Checkbox checked={isSelected} onCheckedChange={onToggle} />
|
||||
</TableCell>
|
||||
<TableCell className="text-center">{globalIndex}</TableCell>
|
||||
<TableCell className="font-mono text-sm">{item.documentNo}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline">{APPROVAL_TYPE_LABELS[item.approvalType]}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="font-medium max-w-[200px] truncate">{item.title}</TableCell>
|
||||
<TableCell>{item.drafter}</TableCell>
|
||||
<TableCell>{item.draftDate}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Badge className={READ_STATUS_COLORS[item.readStatus]}>
|
||||
{READ_STATUS_LABELS[item.readStatus]}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
},
|
||||
|
||||
renderMobileCard: (item, index, globalIndex, handlers) => {
|
||||
const { isSelected, onToggle } = handlers;
|
||||
|
||||
return (
|
||||
<ListMobileCard
|
||||
id={item.id}
|
||||
title={item.title}
|
||||
headerBadges={
|
||||
<div className="flex gap-1">
|
||||
<Badge variant="outline">{APPROVAL_TYPE_LABELS[item.approvalType]}</Badge>
|
||||
<Badge className={READ_STATUS_COLORS[item.readStatus]}>
|
||||
{READ_STATUS_LABELS[item.readStatus]}
|
||||
</Badge>
|
||||
</div>
|
||||
}
|
||||
isSelected={isSelected}
|
||||
onToggleSelection={onToggle}
|
||||
infoGrid={
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<InfoField label="문서번호" value={item.documentNo} />
|
||||
<InfoField label="기안자" value={item.drafter} />
|
||||
<InfoField label="부서" value={item.drafterDepartment} />
|
||||
<InfoField label="직급" value={item.drafterPosition} />
|
||||
<InfoField label="기안일시" value={item.draftDate} />
|
||||
<InfoField label="열람일시" value={item.readAt || '-'} />
|
||||
</div>
|
||||
}
|
||||
actions={
|
||||
<div className="flex gap-2">
|
||||
{item.readStatus === 'unread' ? (
|
||||
<Button
|
||||
variant="default"
|
||||
className="flex-1"
|
||||
onClick={() => {
|
||||
setSelectedItems(new Set([item.id]));
|
||||
setMarkReadDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
<Eye className="w-4 h-4 mr-2" /> 열람 처리
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
onClick={() => {
|
||||
setSelectedItems(new Set([item.id]));
|
||||
setMarkUnreadDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
<EyeOff className="w-4 h-4 mr-2" /> 미열람 처리
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
},
|
||||
|
||||
renderDialogs: () => (
|
||||
<>
|
||||
{/* 열람 처리 확인 다이얼로그 */}
|
||||
<ConfirmDialog
|
||||
open={markReadDialogOpen}
|
||||
onOpenChange={setMarkReadDialogOpen}
|
||||
onConfirm={handleMarkReadConfirm}
|
||||
title="열람 처리"
|
||||
description={`정말 ${selectedItems.size}건을 열람 처리하시겠습니까?`}
|
||||
/>
|
||||
|
||||
{/* 미열람 처리 확인 다이얼로그 */}
|
||||
<ConfirmDialog
|
||||
open={markUnreadDialogOpen}
|
||||
onOpenChange={setMarkUnreadDialogOpen}
|
||||
onConfirm={handleMarkUnreadConfirm}
|
||||
title="미열람 처리"
|
||||
description={`정말 ${selectedItems.size}건을 미열람 처리하시겠습니까?`}
|
||||
/>
|
||||
|
||||
{/* 문서 상세 모달 */}
|
||||
{selectedDocument && (
|
||||
<DocumentDetailModal
|
||||
open={isModalOpen}
|
||||
onOpenChange={setIsModalOpen}
|
||||
documentType={getDocumentType(selectedDocument.approvalType)}
|
||||
data={convertToModalData(selectedDocument)}
|
||||
mode="reference"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
),
|
||||
}), [
|
||||
data,
|
||||
totalCount,
|
||||
totalPages,
|
||||
tableColumns,
|
||||
tabs,
|
||||
activeTab,
|
||||
statCards,
|
||||
startDate,
|
||||
endDate,
|
||||
tableHeaderActions,
|
||||
handleMarkReadClick,
|
||||
handleMarkUnreadClick,
|
||||
handleDocumentClick,
|
||||
markReadDialogOpen,
|
||||
markUnreadDialogOpen,
|
||||
selectedItems.size,
|
||||
handleMarkReadConfirm,
|
||||
handleMarkUnreadConfirm,
|
||||
selectedDocument,
|
||||
isModalOpen,
|
||||
getDocumentType,
|
||||
convertToModalData,
|
||||
]);
|
||||
|
||||
// 모바일 필터 변경 핸들러
|
||||
const handleMobileFilterChange = useCallback((filters: Record<string, string | string[]>) => {
|
||||
if (filters.approvalType) {
|
||||
setFilterOption(filters.approvalType as FilterOption);
|
||||
}
|
||||
if (filters.sort) {
|
||||
setSortOption(filters.sort as SortOption);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<UniversalListPage<ReferenceRecord>
|
||||
config={referenceBoxConfig}
|
||||
initialData={data}
|
||||
initialTotalCount={totalCount}
|
||||
externalPagination={{
|
||||
currentPage,
|
||||
totalPages,
|
||||
totalItems: totalCount,
|
||||
itemsPerPage,
|
||||
onPageChange: setCurrentPage,
|
||||
}}
|
||||
externalSelection={{
|
||||
selectedItems,
|
||||
onToggleSelection: toggleSelection,
|
||||
onToggleSelectAll: toggleSelectAll,
|
||||
getItemId: (item) => item.id,
|
||||
}}
|
||||
onTabChange={handleTabChange}
|
||||
onSearchChange={setSearchQuery}
|
||||
onFilterChange={handleMobileFilterChange}
|
||||
externalIsLoading={isLoading}
|
||||
/>
|
||||
);
|
||||
}
|
||||
89
src/components/approval_backup_v1/ReferenceBox/types.ts
Normal file
89
src/components/approval_backup_v1/ReferenceBox/types.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* 참조함 타입 정의
|
||||
* 열람 상태 기반 탭: 전체, 열람, 미열람
|
||||
*/
|
||||
|
||||
// ===== 메인 탭 타입 =====
|
||||
export type ReferenceTabType = 'all' | 'read' | 'unread';
|
||||
|
||||
// 열람 상태
|
||||
export type ReadStatus = 'read' | 'unread';
|
||||
|
||||
// 결재 유형 (문서 종류): 지출결의서, 품의서, 지출예상내역서
|
||||
export type ApprovalType = 'expense_report' | 'proposal' | 'expense_estimate';
|
||||
|
||||
// 문서 상태
|
||||
export type DocumentStatus = 'pending' | 'approved' | 'rejected';
|
||||
|
||||
// 필터 옵션
|
||||
export type FilterOption = 'all' | 'expense_report' | 'proposal' | 'expense_estimate';
|
||||
|
||||
export const FILTER_OPTIONS: { value: FilterOption; label: string }[] = [
|
||||
{ value: 'all', label: '전체' },
|
||||
{ value: 'expense_report', label: '지출결의서' },
|
||||
{ value: 'proposal', label: '품의서' },
|
||||
{ value: 'expense_estimate', label: '지출예상내역서' },
|
||||
];
|
||||
|
||||
// 정렬 옵션
|
||||
export type SortOption = 'latest' | 'oldest' | 'draftDateAsc' | 'draftDateDesc';
|
||||
|
||||
export const SORT_OPTIONS: { value: SortOption; label: string }[] = [
|
||||
{ value: 'latest', label: '최신순' },
|
||||
{ value: 'oldest', label: '오래된순' },
|
||||
{ value: 'draftDateAsc', label: '기안일 오름차순' },
|
||||
{ value: 'draftDateDesc', label: '기안일 내림차순' },
|
||||
];
|
||||
|
||||
// ===== 참조 문서 레코드 =====
|
||||
export interface ReferenceRecord {
|
||||
id: string;
|
||||
documentNo: string; // 문서번호
|
||||
approvalType: ApprovalType; // 문서유형
|
||||
title: string; // 제목
|
||||
draftDate: string; // 기안일시
|
||||
drafter: string; // 기안자
|
||||
drafterDepartment: string; // 기안자 부서
|
||||
drafterPosition: string; // 기안자 직급
|
||||
documentStatus: DocumentStatus; // 문서 상태 (진행중, 완료, 반려)
|
||||
readStatus: ReadStatus; // 열람 상태
|
||||
readAt?: string; // 열람일시
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
// ===== 상수 정의 =====
|
||||
|
||||
export const REFERENCE_TAB_LABELS: Record<ReferenceTabType, string> = {
|
||||
all: '전체',
|
||||
read: '열람',
|
||||
unread: '미열람',
|
||||
};
|
||||
|
||||
export const APPROVAL_TYPE_LABELS: Record<ApprovalType, string> = {
|
||||
expense_report: '지출결의서',
|
||||
proposal: '품의서',
|
||||
expense_estimate: '지출예상내역서',
|
||||
};
|
||||
|
||||
export const DOCUMENT_STATUS_LABELS: Record<DocumentStatus, string> = {
|
||||
pending: '진행중',
|
||||
approved: '완료',
|
||||
rejected: '반려',
|
||||
};
|
||||
|
||||
export const DOCUMENT_STATUS_COLORS: Record<DocumentStatus, string> = {
|
||||
pending: 'bg-yellow-100 text-yellow-800',
|
||||
approved: 'bg-green-100 text-green-800',
|
||||
rejected: 'bg-red-100 text-red-800',
|
||||
};
|
||||
|
||||
export const READ_STATUS_LABELS: Record<ReadStatus, string> = {
|
||||
read: '열람',
|
||||
unread: '미열람',
|
||||
};
|
||||
|
||||
export const READ_STATUS_COLORS: Record<ReadStatus, string> = {
|
||||
read: 'bg-gray-100 text-gray-800',
|
||||
unread: 'bg-blue-100 text-blue-800',
|
||||
};
|
||||
@@ -402,7 +402,7 @@ export default function ItemManagementClient({
|
||||
value: o.value,
|
||||
label: o.label,
|
||||
})),
|
||||
allOptionLabel: '전체',
|
||||
allOptionLabel: '품목유형 전체',
|
||||
},
|
||||
{
|
||||
key: 'category',
|
||||
@@ -412,7 +412,7 @@ export default function ItemManagementClient({
|
||||
value: c.id,
|
||||
label: c.name,
|
||||
})),
|
||||
allOptionLabel: '전체',
|
||||
allOptionLabel: '카테고리 전체',
|
||||
},
|
||||
{
|
||||
key: 'specification',
|
||||
@@ -422,7 +422,7 @@ export default function ItemManagementClient({
|
||||
value: o.value,
|
||||
label: o.label,
|
||||
})),
|
||||
allOptionLabel: '전체',
|
||||
allOptionLabel: '규격 전체',
|
||||
},
|
||||
{
|
||||
key: 'orderType',
|
||||
@@ -432,7 +432,7 @@ export default function ItemManagementClient({
|
||||
value: o.value,
|
||||
label: o.label,
|
||||
})),
|
||||
allOptionLabel: '전체',
|
||||
allOptionLabel: '구분 전체',
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
@@ -442,7 +442,7 @@ export default function ItemManagementClient({
|
||||
value: o.value,
|
||||
label: o.label,
|
||||
})),
|
||||
allOptionLabel: '전체',
|
||||
allOptionLabel: '상태 전체',
|
||||
},
|
||||
{
|
||||
key: 'sortBy',
|
||||
|
||||
@@ -372,7 +372,7 @@ export function AttendanceManagement() {
|
||||
value: o.value,
|
||||
label: o.label,
|
||||
})),
|
||||
allOptionLabel: '전체',
|
||||
allOptionLabel: '근태 전체',
|
||||
},
|
||||
{
|
||||
key: 'sort',
|
||||
|
||||
@@ -367,7 +367,7 @@ export function EmployeeManagement() {
|
||||
value: o.value,
|
||||
label: o.label,
|
||||
})),
|
||||
allOptionLabel: '전체',
|
||||
allOptionLabel: '계정 전체',
|
||||
},
|
||||
{
|
||||
key: 'sort',
|
||||
|
||||
@@ -629,7 +629,7 @@ export function VacationManagement() {
|
||||
value: o.value,
|
||||
label: o.label,
|
||||
})),
|
||||
allOptionLabel: '전체',
|
||||
allOptionLabel: '휴가 전체',
|
||||
},
|
||||
{
|
||||
key: 'sort',
|
||||
|
||||
@@ -16,6 +16,7 @@ import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { CurrencyInput } from '@/components/ui/currency-input';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -77,16 +78,14 @@ export function PricingTableForm({ mode, initialData }: PricingTableFormProps) {
|
||||
[]
|
||||
);
|
||||
|
||||
const handlePurchasePriceChange = (value: string) => {
|
||||
const num = parseInt(value, 10) || 0;
|
||||
setPurchasePrice(num);
|
||||
setGradePricings((prev) => recalcSellingPrices(num, processingCost, prev));
|
||||
const handlePurchasePriceChange = (value: number) => {
|
||||
setPurchasePrice(value);
|
||||
setGradePricings((prev) => recalcSellingPrices(value, processingCost, prev));
|
||||
};
|
||||
|
||||
const handleProcessingCostChange = (value: string) => {
|
||||
const num = parseInt(value, 10) || 0;
|
||||
setProcessingCost(num);
|
||||
setGradePricings((prev) => recalcSellingPrices(purchasePrice, num, prev));
|
||||
const handleProcessingCostChange = (value: number) => {
|
||||
setProcessingCost(value);
|
||||
setGradePricings((prev) => recalcSellingPrices(purchasePrice, value, prev));
|
||||
};
|
||||
|
||||
const handleGradeChange = (index: number, grade: TradeGrade) => {
|
||||
@@ -327,20 +326,18 @@ export function PricingTableForm({ mode, initialData }: PricingTableFormProps) {
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6 mb-6">
|
||||
<div className="space-y-2">
|
||||
<Label>매입단가</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={purchasePrice || ''}
|
||||
onChange={(e) => handlePurchasePriceChange(e.target.value)}
|
||||
placeholder="숫자 입력"
|
||||
<CurrencyInput
|
||||
value={purchasePrice}
|
||||
onChange={(v) => handlePurchasePriceChange(v ?? 0)}
|
||||
showCurrency={false}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>가공비</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={processingCost || ''}
|
||||
onChange={(e) => handleProcessingCostChange(e.target.value)}
|
||||
placeholder="숫자 입력"
|
||||
<CurrencyInput
|
||||
value={processingCost}
|
||||
onChange={(v) => handleProcessingCostChange(v ?? 0)}
|
||||
showCurrency={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -13,6 +13,7 @@ import { useMenuStore } from '@/stores/menuStore';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { FormField } from '@/components/molecules/FormField';
|
||||
import { CurrencyInput } from '@/components/ui/currency-input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import {
|
||||
@@ -198,12 +199,14 @@ export function RepairForm() {
|
||||
|
||||
{/* Row 4: 수리비용 | 외주업체 | 수리자 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<FormField
|
||||
label="수리비용 (원)"
|
||||
type="number"
|
||||
value={formData.cost}
|
||||
onChange={(v) => handleChange('cost', v)}
|
||||
/>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">수리비용 (원)</Label>
|
||||
<CurrencyInput
|
||||
value={formData.cost ? Number(formData.cost) : undefined}
|
||||
onChange={(v) => handleChange('cost', v ? String(v) : '')}
|
||||
showCurrency={false}
|
||||
/>
|
||||
</div>
|
||||
<FormField
|
||||
label="외주업체"
|
||||
value={formData.vendor}
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { CurrencyInput } from '@/components/ui/currency-input';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -200,11 +201,10 @@ export function MaintenanceFormDialog({
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label>금액 <span className="text-red-500">*</span></Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={form.amount}
|
||||
onChange={(e) => updateField('amount', e.target.value)}
|
||||
placeholder="0"
|
||||
<CurrencyInput
|
||||
value={form.amount ? Number(form.amount) : undefined}
|
||||
onChange={(v) => updateField('amount', v ? String(v) : '')}
|
||||
showCurrency={false}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
|
||||
Reference in New Issue
Block a user