feat: [approval] 전자결재 모듈 대폭 개선 + 회계 리팩토링

- 전자결재: 다양식 지원(11종), 완료함, 동적폼 렌더러, QA 보고서
- 회계: 계정과목 검색모달 리팩토링, 거래처/세금계산서 개선
- HR: 근태/휴가/직원 소소한 수정
- vehicle/quality/pricing 마이너 수정
- approval_backup_v1 백업 보관
This commit is contained in:
유병철
2026-03-16 17:06:02 +09:00
parent 1280c8d61a
commit 0029988e6f
91 changed files with 13202 additions and 1025 deletions

View 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 재검수용

View File

@@ -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 스토리지 활용?)

View File

@@ -0,0 +1,5 @@
import { CompletedBox } from '@/components/approval/CompletedBox';
export default function ApprovalCompletedPage() {
return <CompletedBox />;
}

View File

@@ -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);
}}
/>
);
}

View File

@@ -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);
}}
/>
);
}

View File

@@ -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>

View File

@@ -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>
{/* 가맹점명 + 사업자번호 */}

View File

@@ -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}>

View File

@@ -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);
}}
/>
{/* 삭제 확인 다이얼로그 */}

View File

@@ -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">

View File

@@ -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">

View File

@@ -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',

View File

@@ -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',

View File

@@ -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">

View File

@@ -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>
{/* 품목 + 과세유형 */}

View File

@@ -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);
}}
/>
{/* 삭제 확인 다이얼로그 */}

View File

@@ -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}>

View File

@@ -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[] = [];

View File

@@ -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}

View File

@@ -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',
};

View 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;
}

View 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}
/>
);
}

View 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',
};

View 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>
);
}

View File

@@ -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>
);
}
}

View File

@@ -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>
);
}
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -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">
. &quot; &quot; .
</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>
);
}
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -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 || {};
}
}
}

View 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,
},
};
}

View File

@@ -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"

View File

@@ -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 기반)
}
// 카드 옵션

View File

@@ -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;
}

View 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);
}

View File

@@ -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>
{/* 테이블 */}

View File

@@ -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>

View File

@@ -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>

View 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;
}

View File

@@ -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';

View File

@@ -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; // 회수
}

View File

@@ -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: '문서 복사에 실패했습니다.',
});
}

View File

@@ -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;

View File

@@ -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',
};
// ===== 기안함 현황 통계 =====

View File

@@ -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 || '',

View File

@@ -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,
]);
// 모바일 필터 변경 핸들러

View File

@@ -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> = {

View 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 };
}

View 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}
/>
);
}

View 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',
};

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View 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 {};
}
}

View File

@@ -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: '복제된 문서를 수정 후 상신합니다',
};

View 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="작성 중인 문서를 삭제하시겠습니까?"
/>
</>
);
}

View 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 (카드명)' },
];

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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">&nbsp;</td>
<td className="p-2 border-r border-gray-300">&nbsp;</td>
<td className="p-2">&nbsp;</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>
);
}

View File

@@ -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);
}

View File

@@ -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>
);
}

View 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';

View 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; // 상신 콜백
}

View 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: '결재 회수에 실패했습니다.',
});
}

View 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}
/>
);
}

View 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;
}

View 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 };
}

View 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}
/>
);
}

View 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',
};

View File

@@ -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',

View File

@@ -372,7 +372,7 @@ export function AttendanceManagement() {
value: o.value,
label: o.label,
})),
allOptionLabel: '전체',
allOptionLabel: '근태 전체',
},
{
key: 'sort',

View File

@@ -367,7 +367,7 @@ export function EmployeeManagement() {
value: o.value,
label: o.label,
})),
allOptionLabel: '전체',
allOptionLabel: '계정 전체',
},
{
key: 'sort',

View File

@@ -629,7 +629,7 @@ export function VacationManagement() {
value: o.value,
label: o.label,
})),
allOptionLabel: '전체',
allOptionLabel: '휴가 전체',
},
{
key: 'sort',

View File

@@ -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>

View File

@@ -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}

View File

@@ -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">