diff --git a/claudedocs/[QA-2026-03-16] approval-module-qa-report.md b/claudedocs/[QA-2026-03-16] approval-module-qa-report.md new file mode 100644 index 00000000..38571e94 --- /dev/null +++ b/claudedocs/[QA-2026-03-16] approval-module-qa-report.md @@ -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의 일부로 읽힘 +- 로딩 완료 후: 정상 + +**파일**: +- `src/components/approval/DocumentCreate/PowerOfAttorneyForm.tsx` line 47 +- `src/components/approval/DocumentCreate/SealUsageForm.tsx` line 101 + +**수정 방안**: 로딩 텍스트를 `

` 외부로 이동하거나 `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 로딩) | `

` 내부 로딩 span → `
` 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 재검수용 diff --git a/claudedocs/production/[PLAN-2026-03-13] bending-module-implementation.md b/claudedocs/production/[PLAN-2026-03-13] bending-module-implementation.md new file mode 100644 index 00000000..6057333d --- /dev/null +++ b/claudedocs/production/[PLAN-2026-03-13] bending-module-implementation.md @@ -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 스토리지 활용?) diff --git a/src/app/[locale]/(protected)/approval/completed/page.tsx b/src/app/[locale]/(protected)/approval/completed/page.tsx new file mode 100644 index 00000000..32c82f18 --- /dev/null +++ b/src/app/[locale]/(protected)/approval/completed/page.tsx @@ -0,0 +1,5 @@ +import { CompletedBox } from '@/components/approval/CompletedBox'; + +export default function ApprovalCompletedPage() { + return ; +} diff --git a/src/components/accounting/BadDebtCollection/index.tsx b/src/components/accounting/BadDebtCollection/index.tsx index 643c5aec..351411f7 100644 --- a/src/components/accounting/BadDebtCollection/index.tsx +++ b/src/components/accounting/BadDebtCollection/index.tsx @@ -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: () => ( -
- {/* 거래처 필터 */} - - - {/* 상태 필터 */} - - - {/* 정렬 */} - -
- ), // Stats 카드 computeStats: (): StatCard[] => [ @@ -455,5 +405,15 @@ export function BadDebtCollection({ initialData, initialSummary }: BadDebtCollec ] ); - return ; + return ( + { + 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); + }} + /> + ); } \ No newline at end of file diff --git a/src/components/accounting/BillManagement/index.tsx b/src/components/accounting/BillManagement/index.tsx index 34e89ca6..f870ed41 100644 --- a/src/components/accounting/BillManagement/index.tsx +++ b/src/components/accounting/BillManagement/index.tsx @@ -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
), - // tableHeaderActions: 저장 버튼 + 거래처 필터 + // tableHeaderActions: 저장 버튼 tableHeaderActions: ({ selectedItems }) => ( -
- {/* 저장 버튼 */} - - - {/* 거래처명 필터 */} - -
+ ), // 삭제 확인 메시지 @@ -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); + }} /> ); } \ No newline at end of file diff --git a/src/components/accounting/CardTransactionInquiry/JournalEntryModal.tsx b/src/components/accounting/CardTransactionInquiry/JournalEntryModal.tsx index abe30e96..b0aafabf 100644 --- a/src/components/accounting/CardTransactionInquiry/JournalEntryModal.tsx +++ b/src/components/accounting/CardTransactionInquiry/JournalEntryModal.tsx @@ -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 } {/* 공급가액 + 세액 + 합계금액 */}
- updateItem(index, 'supplyAmount', Number(v) || 0)} - inputClassName="h-8 text-sm" - /> - updateItem(index, 'taxAmount', Number(v) || 0)} - inputClassName="h-8 text-sm" - /> - {/* 합계금액 - readOnly (FormField 미지원, 커스텀 인터랙션 예외) */} +
+ + updateItem(index, 'supplyAmount', v ?? 0)} + className="mt-1 h-8 text-sm" + showCurrency={false} + /> +
+
+ + updateItem(index, 'taxAmount', v ?? 0)} + className="mt-1 h-8 text-sm" + showCurrency={false} + /> +
- {}} + disabled className="mt-1 h-8 text-sm bg-muted/50" + showCurrency={false} />
diff --git a/src/components/accounting/CardTransactionInquiry/ManualInputModal.tsx b/src/components/accounting/CardTransactionInquiry/ManualInputModal.tsx index 121138fd..b59399ab 100644 --- a/src/components/accounting/CardTransactionInquiry/ManualInputModal.tsx +++ b/src/components/accounting/CardTransactionInquiry/ManualInputModal.tsx @@ -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 {/* 공급가액 + 세액 */}
- handleChange('supplyAmount', Number(v) || 0)} - placeholder="0" - /> - handleChange('taxAmount', Number(v) || 0)} - placeholder="0" - /> +
+ + handleChange('supplyAmount', v ?? 0)} + showCurrency={false} + /> +
+
+ + handleChange('taxAmount', v ?? 0)} + showCurrency={false} + /> +
{/* 가맹점명 + 사업자번호 */} diff --git a/src/components/accounting/DepositManagement/index.tsx b/src/components/accounting/DepositManagement/index.tsx index 5360f754..a10b2f64 100644 --- a/src/components/accounting/DepositManagement/index.tsx +++ b/src/components/accounting/DepositManagement/index.tsx @@ -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: ( -
- {/* 거래처 필터 */} - - - {/* 입금유형 필터 */} - - - {/* 정렬 */} - -
- ), // 테이블 하단 합계 행 tableFooter: ( @@ -502,7 +463,15 @@ export function DepositManagement({ initialData, initialPagination: _initialPagi return ( <> - + { + 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); + }} + /> {/* 계정과목명 저장 확인 다이얼로그 */} diff --git a/src/components/accounting/ExpectedExpenseManagement/index.tsx b/src/components/accounting/ExpectedExpenseManagement/index.tsx index a3a39d06..872a260a 100644 --- a/src/components/accounting/ExpectedExpenseManagement/index.tsx +++ b/src/components/accounting/ExpectedExpenseManagement/index.tsx @@ -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: () => ( -
- {/* 거래처 필터 */} - - - {/* 정렬 필터 (최신순/등록순) */} - -
- ), // 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); + }} /> {/* 삭제 확인 다이얼로그 */} diff --git a/src/components/accounting/GeneralJournalEntry/JournalEditModal.tsx b/src/components/accounting/GeneralJournalEntry/JournalEditModal.tsx index df80c786..0920ae3a 100644 --- a/src/components/accounting/GeneralJournalEntry/JournalEditModal.tsx +++ b/src/components/accounting/GeneralJournalEntry/JournalEditModal.tsx @@ -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({ - - handleRowChange(row.id, 'debitAmount', Number(e.target.value) || 0) + + 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} /> - - handleRowChange(row.id, 'creditAmount', Number(e.target.value) || 0) + + 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} /> diff --git a/src/components/accounting/GeneralJournalEntry/ManualJournalEntryModal.tsx b/src/components/accounting/GeneralJournalEntry/ManualJournalEntryModal.tsx index 1007ca14..bfb15412 100644 --- a/src/components/accounting/GeneralJournalEntry/ManualJournalEntryModal.tsx +++ b/src/components/accounting/GeneralJournalEntry/ManualJournalEntryModal.tsx @@ -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({ - - handleRowChange(row.id, 'debitAmount', Number(e.target.value) || 0) + + 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} /> - - handleRowChange(row.id, 'creditAmount', Number(e.target.value) || 0) + + 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} /> diff --git a/src/components/accounting/PurchaseManagement/index.tsx b/src/components/accounting/PurchaseManagement/index.tsx index de97b91d..b7f7d21a 100644 --- a/src/components/accounting/PurchaseManagement/index.tsx +++ b/src/components/accounting/PurchaseManagement/index.tsx @@ -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', diff --git a/src/components/accounting/SalesManagement/index.tsx b/src/components/accounting/SalesManagement/index.tsx index 63b4296a..932571ae 100644 --- a/src/components/accounting/SalesManagement/index.tsx +++ b/src/components/accounting/SalesManagement/index.tsx @@ -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', diff --git a/src/components/accounting/TaxInvoiceIssuance/TaxInvoiceItemTable.tsx b/src/components/accounting/TaxInvoiceIssuance/TaxInvoiceItemTable.tsx index c5ec6248..c22c805d 100644 --- a/src/components/accounting/TaxInvoiceIssuance/TaxInvoiceItemTable.tsx +++ b/src/components/accounting/TaxInvoiceIssuance/TaxInvoiceItemTable.tsx @@ -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 /> - updateItem(item.id, 'unitPrice', Number(e.target.value) || 0)} - placeholder="0" - className="h-8 text-sm text-right" - min={0} + updateItem(item.id, 'unitPrice', v ?? 0)} + className="h-8 text-sm" + showCurrency={false} /> diff --git a/src/components/accounting/TaxInvoiceManagement/ManualEntryModal.tsx b/src/components/accounting/TaxInvoiceManagement/ManualEntryModal.tsx index a2e7e02d..f9308db5 100644 --- a/src/components/accounting/TaxInvoiceManagement/ManualEntryModal.tsx +++ b/src/components/accounting/TaxInvoiceManagement/ManualEntryModal.tsx @@ -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({ {/* 금액 */}
- handleAmountChange('supplyAmount', value)} - placeholder="0" - /> - handleAmountChange('taxAmount', value)} - placeholder="0" - /> - +
+ + handleAmountChange('supplyAmount', v ?? 0)} + showCurrency={false} + /> +
+
+ + handleAmountChange('taxAmount', v ?? 0)} + showCurrency={false} + /> +
+
+ + {}} + disabled + showCurrency={false} + className="bg-muted" + /> +
{/* 품목 + 과세유형 */} diff --git a/src/components/accounting/VendorManagement/VendorManagementClient.tsx b/src/components/accounting/VendorManagement/VendorManagementClient.tsx index beff24e7..20a6b45b 100644 --- a/src/components/accounting/VendorManagement/VendorManagementClient.tsx +++ b/src/components/accounting/VendorManagement/VendorManagementClient.tsx @@ -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: ( -
- {/* 구분 필터 */} - - - {/* 신용등급 필터 */} - - - {/* 거래등급 필터 */} - - - {/* 악성채권 필터 */} - - - {/* 정렬 */} - -
- ), - // 렌더링 함수 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); + }} /> {/* 삭제 확인 다이얼로그 */} diff --git a/src/components/accounting/WithdrawalManagement/index.tsx b/src/components/accounting/WithdrawalManagement/index.tsx index f2557eb4..e3cfb13d 100644 --- a/src/components/accounting/WithdrawalManagement/index.tsx +++ b/src/components/accounting/WithdrawalManagement/index.tsx @@ -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: () => ( -
- {/* 거래처 필터 */} - - - {/* 출금유형 필터 */} - - - {/* 정렬 */} - -
- ), // tableFooter: 합계 행 tableFooter: ( @@ -554,7 +515,15 @@ export function WithdrawalManagement({ initialData, initialPagination: _initialP return ( <> - + { + 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); + }} + /> {/* 계정과목명 저장 확인 다이얼로그 */} diff --git a/src/components/approval/ApprovalBox/actions.ts b/src/components/approval/ApprovalBox/actions.ts index 7d1805af..57cf9040 100644 --- a/src/components/approval/ApprovalBox/actions.ts +++ b/src/components/approval/ApprovalBox/actions.ts @@ -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 = { - '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 = { - '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 = { '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 { + return executeServerAction({ + url: buildApiUrl(`/api/v1/approvals/${id}/hold`), + method: 'POST', + body: { comment: comment || '' }, + errorMessage: '보류 처리에 실패했습니다.', + }); +} + +export async function releaseHoldDocument(id: string, comment?: string): Promise { + 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 { + 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[] = []; diff --git a/src/components/approval/ApprovalBox/index.tsx b/src/components/approval/ApprovalBox/index.tsx index 7f2cd85f..3556c4db 100644 --- a/src/components/approval/ApprovalBox/index.tsx +++ b/src/components/approval/ApprovalBox/index.tsx @@ -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(null); - const [modalData, setModalData] = useState(null); + const [modalData, setModalData] = useState(null); + const [modalDocTypeOverride, setModalDocTypeOverride] = useState(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 = { + 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) + : (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: ( -
- - - -
- ), - renderTableRow: (item, index, globalIndex, handlers) => { const { isSelected, onToggle } = handlers; @@ -723,7 +744,7 @@ export function ApprovalBox() { } actions={ - item.status === 'pending' && isSelected && canApprove ? ( + item.status === 'requested' && isSelected && canApprove ? (
+ } + isSelected={isSelected} + onToggleSelection={onToggle} + infoGrid={ +
+ + + + + + +
+ } + /> + ); + }, + + renderDialogs: () => ( + <> + {selectedDocument && modalData && ( + { + 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) => { + if (filters.approvalType) { + setFilterOption(filters.approvalType as FilterOption); + } + if (filters.sort) { + setSortOption(filters.sort as SortOption); + } + }, []); + + return ( + + 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} + /> + ); +} diff --git a/src/components/approval/CompletedBox/types.ts b/src/components/approval/CompletedBox/types.ts new file mode 100644 index 00000000..fd21d409 --- /dev/null +++ b/src/components/approval/CompletedBox/types.ts @@ -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 = { + all: '전체', + approved: '승인', + rejected: '반려', +}; + +export const APPROVAL_TYPE_LABELS: Record = { + expense_report: '지출결의서', + proposal: '품의서', + expense_estimate: '비용견적서', + document: '문서 결재', +}; + +export const COMPLETED_STATUS_LABELS: Record = { + approved: '승인', + rejected: '반려', +}; + +export const COMPLETED_STATUS_COLORS: Record = { + approved: 'bg-green-100 text-green-800', + rejected: 'bg-red-100 text-red-800', +}; diff --git a/src/components/approval/DocumentCreate/AppointmentCertForm.tsx b/src/components/approval/DocumentCreate/AppointmentCertForm.tsx new file mode 100644 index 00000000..4d160011 --- /dev/null +++ b/src/components/approval/DocumentCreate/AppointmentCertForm.tsx @@ -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 ( +
+ {/* 1. 인적 사항 */} +
+

인적 사항

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + {/* 2. 위촉 정보 */} +
+

위촉 정보

+
+
+ + onChange({ ...data, appointmentPeriodStart: date })} + /> +
+
+ + onChange({ ...data, appointmentPeriodEnd: date })} + /> +
+
+ + onChange({ ...data, contractQualification: e.target.value })} + /> +
+
+
+ + {/* 3. 발급 정보 */} +
+

발급 정보

+
+
+ + onChange({ ...data, purpose: e.target.value })} + /> +
+
+ + onChange({ ...data, issueDate: date })} + /> +
+
+
+
+ ); +} diff --git a/src/components/approval/DocumentCreate/ApprovalLineSection.tsx b/src/components/approval/DocumentCreate/ApprovalLineSection.tsx index f9983e5e..458778f6 100644 --- a/src/components/approval/DocumentCreate/ApprovalLineSection.tsx +++ b/src/components/approval/DocumentCreate/ApprovalLineSection.tsx @@ -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([]); @@ -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 (
@@ -60,7 +72,12 @@ export function ApprovalLineSection({ data, onChange }: ApprovalLineSectionProps
-
부서 / 직책 / 이름
+
+ + 유형 + 부서 / 직책 / 이름 + +
{data.length === 0 ? (
@@ -68,13 +85,32 @@ export function ApprovalLineSection({ data, onChange }: ApprovalLineSectionProps
) : ( data.map((person, index) => ( -
- {index + 1} +
+ {index + 1} + + {/* 결재/합의 선택 */} + + + {/* 직원 선택 */} +
); -} \ No newline at end of file +} diff --git a/src/components/approval/DocumentCreate/BasicInfoSection.tsx b/src/components/approval/DocumentCreate/BasicInfoSection.tsx index e9acaf44..44d45d4f 100644 --- a/src/components/approval/DocumentCreate/BasicInfoSection.tsx +++ b/src/components/approval/DocumentCreate/BasicInfoSection.tsx @@ -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 = { + // 기존 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 (

기본 정보

-
+
{/* 기안자 */}
@@ -50,32 +96,30 @@ export function BasicInfoSection({ data, onChange }: BasicInfoSectionProps) { onChange({ ...data, documentNo: e.target.value })} + disabled + className="bg-gray-50" />
- {/* 문서유형 */} -
- - + {/* 긴급 여부 */} +
+ onChange({ ...data, isUrgent: checked === true })} + /> +
+ + {/* 2단계 양식 선택 */} +
); -} \ No newline at end of file +} diff --git a/src/components/approval/DocumentCreate/BoardMinutesForm.tsx b/src/components/approval/DocumentCreate/BoardMinutesForm.tsx new file mode 100644 index 00000000..b88ea146 --- /dev/null +++ b/src/components/approval/DocumentCreate/BoardMinutesForm.tsx @@ -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 ( +
+ {/* 1. 일시/장소 */} +
+

일시 / 장소

+
+
+ + onChange({ ...data, meetingDate: date })} + /> +
+
+ + onChange({ ...data, meetingPlace: e.target.value })} + /> +
+
+
+ + {/* 2. 출석 이사 / 감사 */} +
+

출석 이사 / 감사

+
+
+ + onChange({ ...data, totalDirectors: Number(e.target.value) })} + /> +
+
+ + onChange({ ...data, attendingDirectors: Number(e.target.value) })} + /> +
+
+ + onChange({ ...data, totalAuditors: Number(e.target.value) })} + /> +
+
+ + onChange({ ...data, attendingAuditors: Number(e.target.value) })} + /> +
+
+
+ + {/* 3. 의안 */} +
+

의안

+
+
+ + onChange({ ...data, agendaTitle: e.target.value })} + /> +
+
+ + +
+
+
+ + {/* 4. 의사경과 */} +
+

의사경과

+
+
+ + onChange({ ...data, chairperson: e.target.value })} + /> +
+
+ +