From 1d7b02869385b606fa0e5b134b53cab98fbd0443 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A0=EB=B3=91=EC=B2=A0?= Date: Mon, 19 Jan 2026 17:31:28 +0900 Subject: [PATCH] =?UTF-8?q?feat(WEB):=20Phase=202-3=20V2=20=EB=A7=88?= =?UTF-8?q?=EC=9D=B4=EA=B7=B8=EB=A0=88=EC=9D=B4=EC=85=98=20=EC=99=84?= =?UTF-8?q?=EB=A3=8C=20=EB=B0=8F=20ServerErrorPage=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2 완료 (4개): - 노무관리, 단가관리(건설), 입금, 출금 Phase 3 라우팅 구조 변경 완료 (22개): - 거래처(영업), 팝업관리, 공정관리, 게시판관리, 대손추심, Q&A - 현장관리, 실행내역, 견적관리, 견적(테스트) - 입찰관리, 이슈관리, 현장설명회, 견적서(건설) - 협력업체, 시공관리, 기성관리, 품목관리(건설) - 회계 도메인: 거래처, 매출, 세금계산서, 매입 신규 컴포넌트: - ErrorCard: 에러 페이지 UI 통일 - ServerErrorPage: V2 페이지 에러 처리 필수 - V2 Client 컴포넌트 및 Config 파일들 총 47개 상세 페이지 중 28개 완료, 19개 제외/불필요 Co-Authored-By: Claude Opus 4.5 --- .../[ANALYSIS] common-component-patterns.md | 158 ++ ...L] integrated-detail-template-checklist.md | 1664 ++++++++++------- ...LAN] detail-page-pattern-classification.md | 155 ++ .../[REF] chrome-devtools-mcp-emoji-issue.md | 118 ++ .../bad-debt-collection/[id]/edit/page.tsx | 57 +- .../bad-debt-collection/[id]/page.tsx | 53 +- .../bad-debt-collection/new/page.tsx | 6 +- .../accounting/deposits/[id]/edit/page.tsx | 14 + .../accounting/deposits/[id]/page.tsx | 17 +- .../accounting/deposits/new/page.tsx | 7 + .../accounting/withdrawals/[id]/edit/page.tsx | 14 + .../accounting/withdrawals/[id]/page.tsx | 17 +- .../accounting/withdrawals/new/page.tsx | 7 + .../board/board-management/[id]/edit/page.tsx | 122 +- .../board/board-management/[id]/page.tsx | 139 +- .../board/board-management/new/page.tsx | 67 +- .../[id]/edit/page.tsx | 54 +- .../progress-billing-management/[id]/page.tsx | 44 +- .../order/base-info/labor/[id]/page.tsx | 7 +- .../order/base-info/labor/new/page.tsx | 6 +- .../base-info/pricing/[id]/edit/page.tsx | 4 +- .../order/base-info/pricing/[id]/page.tsx | 4 +- .../order/base-info/pricing/new/page.tsx | 6 +- .../order/site-management/[id]/edit/page.tsx | 50 +- .../order/site-management/[id]/page.tsx | 47 +- .../order/structure-review/[id]/edit/page.tsx | 55 +- .../order/structure-review/[id]/page.tsx | 50 +- .../project/bidding/[id]/edit/page.tsx | 37 +- .../project/bidding/[id]/page.tsx | 42 +- .../bidding/estimates/[id]/edit/page.tsx | 60 +- .../project/bidding/estimates/[id]/page.tsx | 55 +- .../bidding/partners/[id]/edit/page.tsx | 55 +- .../project/bidding/partners/[id]/page.tsx | 35 +- .../bidding/site-briefings/[id]/edit/page.tsx | 54 +- .../bidding/site-briefings/[id]/page.tsx | 35 +- .../[id]/edit/page.tsx | 21 +- .../construction-management/[id]/page.tsx | 8 +- .../issue-management/[id]/edit/page.tsx | 59 +- .../project/issue-management/[id]/page.tsx | 56 +- .../customer-center/qna/[id]/edit/page.tsx | 68 +- .../customer-center/qna/[id]/page.tsx | 141 +- .../customer-center/qna/create/page.tsx | 13 +- .../process-management/[id]/edit/page.tsx | 57 +- .../process-management/[id]/page.tsx | 58 +- .../process-management/new/page.tsx | 18 +- .../[id]/edit/page.tsx | 82 +- .../[id]/page.tsx | 128 +- .../new/page.tsx | 32 +- .../sales/quote-management/[id]/edit/page.tsx | 109 +- .../sales/quote-management/[id]/page.tsx | 57 +- .../quote-management/test/[id]/edit/page.tsx | 161 +- .../sales/quote-management/test/[id]/page.tsx | 43 +- .../popup-management/[id]/edit/page.tsx | 44 +- .../settings/popup-management/[id]/page.tsx | 98 +- .../settings/popup-management/new/page.tsx | 12 +- src/app/api/proxy/[...path]/route.ts | 17 +- .../BadDebtCollection/BadDebtDetail.tsx | 2 +- .../BadDebtDetailClientV2.tsx | 135 ++ .../accounting/BadDebtCollection/index.tsx | 4 +- .../accounting/BillManagement/BillDetail.tsx | 6 +- .../DepositManagement/DepositDetail.tsx | 4 +- .../DepositDetailClientV2.tsx | 120 ++ .../DepositManagement/depositDetailConfig.ts | 123 ++ .../SalesManagement/SalesDetail.tsx | 2 +- .../WithdrawalManagement/WithdrawalDetail.tsx | 4 +- .../WithdrawalDetailClientV2.tsx | 120 ++ .../withdrawalDetailConfig.ts | 123 ++ .../BoardManagement/BoardDetailClientV2.tsx | 329 ++++ .../board/BoardManagement/index.tsx | 2 + .../labor-management/LaborDetailClientV2.tsx | 122 ++ .../construction/labor-management/index.tsx | 2 + .../labor-management/laborDetailConfig.ts | 113 ++ .../PricingDetailClientV2.tsx | 136 ++ .../construction/pricing-management/index.ts | 3 + .../pricing-management/pricingDetailConfig.ts | 174 ++ .../site-management/SiteDetailClientV2.tsx | 140 ++ .../site-management/SiteDetailForm.tsx | 2 +- .../StructureReviewDetailClientV2.tsx | 148 ++ .../StructureReviewDetailForm.tsx | 2 +- .../clients/ClientDetailClientV2.tsx | 229 +++ src/components/clients/actions.ts | 257 +++ src/components/clients/clientDetailConfig.ts | 276 +++ .../InquiryManagement/InquiryDetail.tsx | 2 +- .../InquiryDetailClientV2.tsx | 221 +++ .../InquiryManagement/index.tsx | 1 + .../AttendanceInfoDialog.tsx | 2 +- .../AttendanceManagement/ReasonInfoDialog.tsx | 4 +- .../ProcessDetailClientV2.tsx | 136 ++ src/components/process-management/index.ts | 1 + .../PopupManagement/PopupDetailClientV2.tsx | 201 ++ .../settings/PopupManagement/index.tsx | 1 + .../PopupManagement/popupDetailConfig.ts | 191 ++ .../IntegratedDetailTemplate/FieldInput.tsx | 283 +++ .../components/DetailActions.tsx | 145 ++ .../components/DetailField.tsx | 90 + .../components/DetailGrid.tsx | 59 + .../components/DetailSection.tsx | 96 + .../components/index.ts | 21 + .../skeletons/DetailFieldSkeleton.tsx | 47 + .../skeletons/DetailGridSkeleton.tsx | 60 + .../skeletons/DetailSectionSkeleton.tsx | 52 + .../components/skeletons/index.ts | 9 + .../IntegratedDetailTemplate/index.tsx | 287 +-- .../IntegratedDetailTemplate/types.ts | 6 +- src/components/ui/error-card.tsx | 174 ++ src/hooks/useClientList.ts | 29 +- src/lib/api/fetch-wrapper.ts | 32 +- src/middleware.ts | 46 +- tsconfig.tsbuildinfo | 2 +- 109 files changed, 6811 insertions(+), 2562 deletions(-) create mode 100644 claudedocs/[ANALYSIS] common-component-patterns.md create mode 100644 claudedocs/dev/[PLAN] detail-page-pattern-classification.md create mode 100644 claudedocs/dev/[REF] chrome-devtools-mcp-emoji-issue.md create mode 100644 src/app/[locale]/(protected)/accounting/deposits/[id]/edit/page.tsx create mode 100644 src/app/[locale]/(protected)/accounting/deposits/new/page.tsx create mode 100644 src/app/[locale]/(protected)/accounting/withdrawals/[id]/edit/page.tsx create mode 100644 src/app/[locale]/(protected)/accounting/withdrawals/new/page.tsx create mode 100644 src/components/accounting/BadDebtCollection/BadDebtDetailClientV2.tsx create mode 100644 src/components/accounting/DepositManagement/DepositDetailClientV2.tsx create mode 100644 src/components/accounting/DepositManagement/depositDetailConfig.ts create mode 100644 src/components/accounting/WithdrawalManagement/WithdrawalDetailClientV2.tsx create mode 100644 src/components/accounting/WithdrawalManagement/withdrawalDetailConfig.ts create mode 100644 src/components/board/BoardManagement/BoardDetailClientV2.tsx create mode 100644 src/components/business/construction/labor-management/LaborDetailClientV2.tsx create mode 100644 src/components/business/construction/labor-management/laborDetailConfig.ts create mode 100644 src/components/business/construction/pricing-management/PricingDetailClientV2.tsx create mode 100644 src/components/business/construction/pricing-management/pricingDetailConfig.ts create mode 100644 src/components/business/construction/site-management/SiteDetailClientV2.tsx create mode 100644 src/components/business/construction/structure-review/StructureReviewDetailClientV2.tsx create mode 100644 src/components/clients/ClientDetailClientV2.tsx create mode 100644 src/components/clients/actions.ts create mode 100644 src/components/clients/clientDetailConfig.ts create mode 100644 src/components/customer-center/InquiryManagement/InquiryDetailClientV2.tsx create mode 100644 src/components/process-management/ProcessDetailClientV2.tsx create mode 100644 src/components/settings/PopupManagement/PopupDetailClientV2.tsx create mode 100644 src/components/settings/PopupManagement/popupDetailConfig.ts create mode 100644 src/components/templates/IntegratedDetailTemplate/FieldInput.tsx create mode 100644 src/components/templates/IntegratedDetailTemplate/components/DetailActions.tsx create mode 100644 src/components/templates/IntegratedDetailTemplate/components/DetailField.tsx create mode 100644 src/components/templates/IntegratedDetailTemplate/components/DetailGrid.tsx create mode 100644 src/components/templates/IntegratedDetailTemplate/components/DetailSection.tsx create mode 100644 src/components/templates/IntegratedDetailTemplate/components/index.ts create mode 100644 src/components/templates/IntegratedDetailTemplate/components/skeletons/DetailFieldSkeleton.tsx create mode 100644 src/components/templates/IntegratedDetailTemplate/components/skeletons/DetailGridSkeleton.tsx create mode 100644 src/components/templates/IntegratedDetailTemplate/components/skeletons/DetailSectionSkeleton.tsx create mode 100644 src/components/templates/IntegratedDetailTemplate/components/skeletons/index.ts create mode 100644 src/components/ui/error-card.tsx diff --git a/claudedocs/[ANALYSIS] common-component-patterns.md b/claudedocs/[ANALYSIS] common-component-patterns.md new file mode 100644 index 00000000..538ae3b7 --- /dev/null +++ b/claudedocs/[ANALYSIS] common-component-patterns.md @@ -0,0 +1,158 @@ +# 공통 컴포넌트 패턴 분석 + +> Phase 3 마이그레이션 진행하면서 발견되는 공통화 후보 패턴 수집 +> 페이지 마이그레이션 완료 후 이 리스트 기반으로 공통 컴포넌트 설계 + +## 최종 목표: IntegratedDetailTemplate Config 통합 + +공통 컴포넌트 추출 후 `IntegratedDetailTemplate`의 필드 config 옵션으로 통합 + +**현재 지원 타입:** +```typescript +type: 'text' | 'select' | 'date' | 'textarea' | 'number' | 'checkbox' +``` + +**확장 예정 타입:** +```typescript +// 주소 입력 +{ name: 'address', label: '주소', type: 'address', withCoords?: boolean } + +// 파일 업로드 +{ name: 'files', label: '첨부파일', type: 'file-upload', maxSize?: number, multiple?: boolean, accept?: string } + +// 음성 메모 +{ name: 'memo', label: '메모', type: 'voice-memo' } + +// 삭제 확인 (페이지 레벨 옵션) +deleteConfirm?: { title: string, message: string } +``` + +**장점:** +- 페이지별 config만 수정하면 UI 자동 구성 +- 일관된 UX/UI 보장 +- 유지보수 용이 + +## 발견된 패턴 목록 + +### 1. 주소 입력 (Address Input) +| 발견 위치 | 구성 요소 | 특이사항 | +|-----------|-----------|----------| +| site-management/SiteDetailForm | 우편번호 찾기 버튼 + 주소 Input + 상세주소 | 경도/위도 필드 별도 | + +**공통 요소:** +- Daum Postcode API 연동 (`useDaumPostcode` 훅 이미 존재) +- 우편번호 찾기 버튼 +- 기본주소 (자동 입력) +- 상세주소 (수동 입력) + +**변형 가능성:** +- 경도/위도 필드 포함 여부 +- 읽기 전용 모드 지원 + +--- + +### 2. 파일 업로드 (File Upload) +| 발견 위치 | 구성 요소 | 특이사항 | +|-----------|-----------|----------| +| site-management/SiteDetailForm | 드래그앤드롭 영역 + 파일 목록 | 10MB 제한, 다중 파일 | +| structure-review/StructureReviewDetailForm | 드래그앤드롭 영역 + 파일 목록 | 10MB 제한, 다중 파일 (동일 패턴) | + +**공통 요소:** +- 드래그앤드롭 영역 (점선 박스) +- 클릭하여 파일 선택 +- 드래그 중 시각적 피드백 +- 파일 크기 검증 + +**변형 가능성:** +- 허용 파일 타입 (이미지만 / 문서만 / 전체) +- 단일 vs 다중 파일 +- 최대 파일 크기 +- 최대 파일 개수 + +--- + +### 3. 파일 목록 (File List) +| 발견 위치 | 구성 요소 | 특이사항 | +|-----------|-----------|----------| +| site-management/SiteDetailForm | 파일명 + 크기 + 다운로드/삭제 | view/edit 모드별 다른 액션 | +| structure-review/StructureReviewDetailForm | 파일명 + 크기 + 다운로드/삭제 | 동일 패턴 | + +**공통 요소:** +- 파일 아이콘 +- 파일명 표시 +- 파일 크기 표시 +- 액션 버튼 + +**변형 가능성:** +- view 모드: 다운로드 버튼 +- edit 모드: 삭제(X) 버튼 +- 미리보기 지원 (이미지) +- 업로드 날짜 표시 여부 + +--- + +### 4. 음성 녹음 (Voice Recorder) +| 발견 위치 | 구성 요소 | 특이사항 | +|-----------|-----------|----------| +| site-management/SiteDetailForm | 녹음 버튼 (Textarea 내부) | edit 모드에서만 표시 | + +**공통 요소:** +- 녹음 시작/중지 버튼 +- Textarea와 연동 (STT 결과 입력) + +**변형 가능성:** +- 버튼 위치 (Textarea 내부 / 외부) +- 녹음 시간 제한 +- 녹음 중 시각적 피드백 + +--- + +### 5. 삭제 확인 다이얼로그 (Delete Confirmation Dialog) +| 발견 위치 | 구성 요소 | 특이사항 | +|-----------|-----------|----------| +| structure-review/StructureReviewDetailForm | AlertDialog + 제목 + 설명 + 취소/삭제 버튼 | view 모드에서 삭제 버튼 클릭 시 | + +**공통 요소:** +- AlertDialog 컴포넌트 사용 +- 제목: "[항목명] 삭제" +- 설명: 삭제 확인 메시지 + 되돌릴 수 없음 경고 +- 취소/삭제 버튼 + +**변형 가능성:** +- 항목명 커스터마이징 +- 삭제 후 리다이렉트 경로 +- 추가 경고 메시지 + +--- + +## 추가 예정 + +> Phase 3 마이그레이션 진행하면서 새로운 패턴 발견 시 여기에 추가 + +### 예상 패턴 (확인 필요) +- [ ] 이미지 미리보기 (썸네일) +- [ ] 서명 입력 +- [ ] 날짜/시간 선택 +- [ ] 검색 가능한 Select (Combobox) +- [ ] 태그 입력 +- [ ] 금액 입력 (천단위 콤마) + +--- + +## 공통화 우선순위 (마이그레이션 완료 후 결정) + +| 우선순위 | 패턴 | 사용 빈도 | 복잡도 | +|----------|------|-----------|--------| +| - | 주소 입력 | 1 | 중 | +| - | 파일 업로드 | 2 | 상 | +| - | 파일 목록 | 2 | 중 | +| - | 음성 녹음 | 1 | 상 | +| - | 삭제 확인 다이얼로그 | 1 | 하 | + +> 사용 빈도는 마이그레이션 진행하면서 카운트 + +--- + +## 변경 이력 +- 2025-01-19: 초안 작성, site-management에서 4개 패턴 발견 +- 2025-01-19: structure-review에서 동일 패턴 확인 (파일 업로드/목록), 삭제 확인 다이얼로그 패턴 추가 diff --git a/claudedocs/[IMPL] integrated-detail-template-checklist.md b/claudedocs/[IMPL] integrated-detail-template-checklist.md index 5560ab6f..07ddc837 100644 --- a/claudedocs/[IMPL] integrated-detail-template-checklist.md +++ b/claudedocs/[IMPL] integrated-detail-template-checklist.md @@ -1,668 +1,1060 @@ -# IntegratedDetailTemplate 구현 체크리스트 +# IntegratedDetailTemplate 통합 구현 체크리스트 > 브랜치: `feature/universal-detail-component` > 작성일: 2026-01-17 -> 최종 수정: 2026-01-17 (v2 - 심층 검토 반영) +> 최종 수정: 2026-01-19 (v24 - Phase 3 최종 분석 완료: 28개 완료, 0개 대기, 19개 제외/불필요) --- -## ⚠️ 예외 처리 프로세스 +## 작업 범위 개요 -### 예외 상황 발생 시 절차 +### 두 가지 병행 작업 -마이그레이션 중 예외 상황 발생 시 다음 절차를 따릅니다: +``` +┌─────────────────────────────────────────────────────┐ +│ Work A: IntegratedDetailTemplate 양산 마이그레이션 │ +│ (감싸는 껍데기 - 페이지 레벨) │ +│ ┌───────────────────────────────────────────────┐ │ +│ │ PageLayout + PageHeader + Card │ │ +│ │ ┌─────────────────────────────────────────┐ │ │ +│ │ │ Work B: 내부 컴포넌트 공통화 │ │ │ +│ │ │ (DetailSection, DetailGrid 등) │ │ │ +│ │ │ - 섹션 구조 │ │ │ +│ │ │ - 그리드 레이아웃 │ │ │ +│ │ │ - 필드 배치 │ │ │ +│ │ └─────────────────────────────────────────┘ │ │ +│ │ 버튼 영역 (목록/삭제/수정/저장) │ │ +│ └───────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────┘ +``` + +| 작업 | 설명 | 상태 | +|------|------|------| +| **Work A** | IntegratedDetailTemplate 양산 마이그레이션 | ✅ Phase 1 완료 (계좌/카드) | +| **Work B** | 상세 페이지 내부 컴포넌트 공통화 | ✅ 완료 (B.2.4 이슈 해결) | + +--- + +## 📊 47개 상세 페이지 전체 분석 (v8) + +### 페이지 분류 기준 + +| 분류 | 설명 | 적용 가능 여부 | +|------|------|---------------| +| **A. 단순 CRUD** | 표준 등록/조회/수정 패턴 | ✅ IntegratedDetailTemplate 바로 적용 | +| **B. 중간 복잡도** | 섹션 구조, 일부 커스텀 필요 | ⚠️ 커스텀 섹션으로 적용 가능 | +| **C. 복잡 구조** | 칸반, 탭, 트리, 다중 테이블 | ❌ 제외 (독자 구조 유지) | +| **D. 모달 형태** | Dialog 기반 등록/상세 | ❌ 제외 | +| **E. 조회 전용** | 수정 기능 없음 | ⚠️ view 모드만 사용 | + +--- + +### 도메인별 상세 분석 + +#### 🏦 회계 (Accounting) - 8개 + +| 페이지 | 경로 | 분류 | 특이사항 | Phase | +|--------|------|------|----------|-------| +| 거래처 | `/accounting/vendors/[id]` | B | 30+ 필드, 7 섹션, 이미지 업로드 | Phase 3 | +| 거래처원장 | `/accounting/vendor-ledger/[id]` | B | 원장/거래내역 탭 구조 | Phase 3 | +| 매출 | `/accounting/sales/[id]` | B | **품목 테이블 포함**, 세금계산서/거래명세서 섹션 | Phase 3 (재분류) | +| 입금 | `/accounting/deposits/[id]` | A | ✅ 완료, 상단 버튼 | Phase 2 ✅ | +| 세금계산서 | `/accounting/bills/[id]` | B | **차수 관리 테이블 포함** | Phase 3 (재분류) | +| 출금 | `/accounting/withdrawals/[id]` | A | ✅ 완료, 상단 버튼 | Phase 2 ✅ | +| 매입 | `/accounting/purchase/[id]` | B | **품목 테이블 + 문서 모달 포함** | Phase 3 (재분류) | +| 대손추심 | `/accounting/bad-debt-collection/[id]` | B | 추심 상태 추적 | Phase 3 | + +**소계**: 2개 완료 (입금, 출금), 6개 커스텀 필요 (Phase 3) + +--- + +#### 🏗️ 건설 (Construction) - 16개 + +| 페이지 | 경로 | 분류 | 특이사항 | Phase | +|--------|------|------|----------|-------| +| 수주관리 | `/construction/order-management/[id]` | C | 계약, 결제, 다단계 승인 | 제외 | +| 현장관리 | `/construction/site-management/[id]` | B | 현장 정보 + 상태 | Phase 3 | +| 실행내역 | `/construction/structure-review/[id]` | B | 계층 구조 데이터 | Phase 3 | +| 품목관리(건설) | `/construction/base-info/items/[id]` | B | 품목 상세 | Phase 3 | +| 단가관리 | `/construction/base-info/pricing/[id]` | A | 단순 단가 정보 | Phase 2 | +| 노무관리 | `/construction/base-info/labor/[id]` | A | 단순 노무 정보 | Phase 2 | +| 계약서 | `/construction/contract/[id]` | C | 계약 문서, 승인 플로우 | 제외 | +| 인수인계서 | `/construction/handover-report/[id]` | C | 문서 생성, 프린트 | 제외 | +| 현장종합현황 | `/construction/project/management/[id]` | C | 칸반 보드 | 제외 | +| 이슈관리 | `/construction/issue-management/[id]` | B | 이슈 상세 + 댓글 | Phase 3 | +| 입찰관리 | `/construction/bidding/[id]` | B | 입찰 정보 + 첨부파일 | Phase 3 | +| 현장설명회 | `/construction/site-briefings/[id]` | B | 설명회 정보 | Phase 3 | +| 견적서 | `/construction/estimates/[id]` | B | 견적 상세 + 품목 | Phase 3 | +| 협력업체 | `/construction/partners/[id]` | B | 업체 정보 | Phase 3 | +| 시공관리 | `/construction/construction-management/[id]` | B | 시공 상세 | Phase 3 | +| 기성관리 | `/construction/progress-billing/[id]` | B | 기성 내역 | Phase 3 | + +**소계**: 2개 적용 가능 (Phase 2), 10개 커스텀 필요 (Phase 3), 4개 제외 + +--- + +#### 💼 판매 (Sales) - 6개 + +| 페이지 | 경로 | 분류 | 특이사항 | Phase | +|--------|------|------|----------|-------| +| 거래처(영업) | `/sales/client-management-sales-admin/[id]` | A | 하단 버튼 | Phase 2 | +| 견적관리 | `/sales/quote-management/[id]` | B | 견적서 출력, 품목 테이블 | Phase 3 | +| 견적(테스트) | `/sales/quote-management/test/[id]` | B | V2 UI 테스트, 실제 API 연동 예정 | Phase 3 ✅ | +| 수주관리 | `/sales/order-management/[id]` | B | 주문 추적 | Phase 3 | +| 생산의뢰 | `/sales/production-orders/[id]` | B | 의뢰 상세 | Phase 3 | +| 단가관리 | `/sales/pricing-management/[id]` | B | 단가 테이블 | Phase 3 | + +**소계**: 1개 적용 가능 (Phase 2), 5개 커스텀 필요 (Phase 3) - 견적(테스트) 포함 + +--- + +#### 👥 인사 (HR) - 2개 + +| 페이지 | 경로 | 분류 | 특이사항 | Phase | +|--------|------|------|----------|-------| +| 카드관리 | `/hr/card-management/[id]` | A | ✅ 완료 | Phase 1 | +| 사원관리 | `/hr/employee-management/[id]` | C | 40+ 필드, 탭 구조 | 제외 | + +**소계**: 1개 완료, 1개 제외 + +--- + +#### 🏭 생산 (Production) - 2개 + +| 페이지 | 경로 | 분류 | 특이사항 | Phase | +|--------|------|------|----------|-------| +| 작업지시 | `/production/work-orders/[id]` | C | 칸반, 공정 단계, 동적 테이블 | 제외 | +| 스크린생산 | `/production/screen-production/[id]` | B | 생산 상세 | Phase 3 | + +**소계**: 1개 커스텀 필요 (Phase 3), 1개 제외 + +--- + +#### 🔍 품질 (Quality) - 1개 + +| 페이지 | 경로 | 분류 | 특이사항 | Phase | +|--------|------|------|----------|-------| +| 검수관리 | `/quality/inspections/[id]` | C | 동적 계산, 측정값 입력 | 제외 | + +**소계**: 1개 제외 + +--- + +#### 📦 출고 (Outbound) - 1개 + +| 페이지 | 경로 | 분류 | 특이사항 | Phase | +|--------|------|------|----------|-------| +| 출하관리 | `/outbound/shipments/[id]` | C | 3종 문서 출력, 상태별 UI | 제외 | + +**소계**: 1개 제외 + +--- + +#### 📥 자재 (Material) - 2개 + +| 페이지 | 경로 | 분류 | 특이사항 | Phase | +|--------|------|------|----------|-------| +| 재고현황 | `/material/stock-status/[id]` | B | **LOT 테이블 포함**, 조회 전용 복합 UI | Phase 3 (재분류) | +| 입고관리 | `/material/receiving-management/[id]` | B | **복잡한 워크플로우**, 다중 다이얼로그 | Phase 3 (재분류) | + +**소계**: 2개 커스텀 필요 (Phase 3) + +--- + +#### 📞 고객센터 (Customer Center) - 3개 + +| 페이지 | 경로 | 분류 | 특이사항 | Phase | +|--------|------|------|----------|-------| +| 공지사항 | `/customer-center/notices/[id]` | E | 조회 전용 | Phase 2 | +| 이벤트 | `/customer-center/events/[id]` | E | 조회 전용 | Phase 2 | +| Q&A | `/customer-center/qna/[id]` | B | 스레드 답변 구조 | Phase 3 | + +**소계**: 2개 조회전용 (Phase 2), 1개 커스텀 필요 (Phase 3) + +--- + +#### 📋 게시판 (Board) - 1개 + +| 페이지 | 경로 | 분류 | 특이사항 | Phase | +|--------|------|------|----------|-------| +| 게시판관리 | `/board/board-management/[id]` | A | 단순 CRUD | Phase 2 | + +**소계**: 1개 적용 가능 (Phase 2) + +--- + +#### ⚙️ 설정 (Settings) - 3개 + +| 페이지 | 경로 | 분류 | 특이사항 | Phase | +|--------|------|------|----------|-------| +| 계좌관리 | `/settings/accounts/[id]` | A | ✅ 완료 | Phase 1 | +| 팝업관리 | `/settings/popup-management/[id]` | A | RichTextEditor | Phase 2 | +| 권한관리 | `/settings/permissions/[id]` | C | Matrix UI | 제외 | + +**소계**: 1개 완료, 1개 적용 가능 (Phase 2), 1개 제외 + +--- + +#### 🔧 기준정보 (Master Data) - 1개 + +| 페이지 | 경로 | 분류 | 특이사항 | Phase | +|--------|------|------|----------|-------| +| 공정관리 | `/master-data/process-management/[id]` | A | RuleModal 포함 | Phase 2 | + +**소계**: 1개 적용 가능 (Phase 2) + +--- + +#### 📦 품목 (Items) - 1개 + +| 페이지 | 경로 | 분류 | 특이사항 | Phase | +|--------|------|------|----------|-------| +| 품목관리 | `/items/[id]` | D | DynamicItemForm | 제외 | + +**소계**: 1개 제외 (DynamicForm 사용) + +--- + +## 📋 리스트 페이지 vs 상세 페이지 차이 설명 + +> test-urls 리스트 페이지 수가 상세([id]) 페이지 수보다 많은 이유 + +### 상세 페이지가 없는 경우 + +#### 1. 모달 형태 (별도 [id] 페이지 없음) +리스트에서 클릭 시 **Dialog 모달**로 상세/등록/수정 처리 + +| 리스트 페이지 | 상세 형태 | +|-------------|----------| +| 직급관리 `/settings/ranks` | 모달 | +| 직책관리 `/settings/titles` | 모달 | +| 근태관리 `/hr/attendance-management` | 모달 | +| 휴가관리 `/hr/vacation-management` | 모달 | +| 부서관리 `/hr/department-management` | 트리 + 모달 | + +#### 2. 조회 전용 / 보고서 형태 +등록/수정 기능 없이 **데이터 조회만** 하는 페이지 + +| 페이지 | 특징 | +|-------|------| +| 일일 일보 | 보고서 | +| 지출 예상 내역서 | 보고서 | +| 미수금 현황 | 보고서 | +| 입출금 계좌조회 | 조회 전용 | +| 카드 내역 조회 | 조회 전용 | +| 종합 경영 분석 | 대시보드 | + +#### 3. 설정 페이지 (단일 페이지) +[id]가 아닌 **설정 폼 하나**로 구성 + +| 페이지 | 특징 | +|-------|------| +| 휴가정책 `/settings/leave-policy` | 설정 폼 | +| 근무일정 `/settings/work-schedule` | 설정 폼 | +| 출퇴근관리 `/settings/attendance-settings` | 설정 폼 | +| 알림설정 `/settings/notification-settings` | 설정 폼 | +| 계정정보, 회사정보 | 프로필 형태 | + +#### 4. 특수 구조 +| 페이지 | 특징 | +|-------|------| +| 전자결재 (기안/결재/참조함) | 워크플로우 구조 | +| 대시보드 | 상세 개념 없음 | + +### 결론 +``` +IntegratedDetailTemplate 적용 대상 = 실제 [id] 폴더가 있는 페이지 기반 상세 += 47개 (모달/보고서/설정 페이지 제외) +``` + +--- + +## 📈 통계 요약 + +### 전체 현황 + +| 분류 | 개수 | 비율 | +|------|------|------| +| **A. 단순 CRUD** | 16개 | 34% | +| **B. 중간 복잡도** | 17개 | 36% | +| **C. 복잡 구조** | 11개 | 23% | +| **D. DynamicForm** | 1개 | 2% | +| **E. 조회 전용** | 2개 | 4% | +| **합계** | **47개** | 100% | + +### Phase별 분류 + +| Phase | 상태 | 대상 페이지 수 | 설명 | +|-------|------|---------------|------| +| **Phase 1** | ✅ 완료 | 2개 | 계좌관리, 카드관리 | +| **Phase 2** | ✅ 완료 | 4개 | 노무, 단가, 입금, 출금 | +| **Phase 3** | 🔄 대기 | 27개 | 중간 복잡도 (별도 라우팅 4개 추가) | +| **마이그레이션 불필요** | ❌ | 2개 | View only (공지사항, 이벤트) | +| **제외** | ❌ | 12개 | 복잡 구조, DynamicForm | +| **합계** | - | **47개** | - | + +> **v14 재분류**: 매출, 세금계산서, 매입, 재고현황, 입고관리 → Phase 3 이동 (내부 테이블/복잡 워크플로우) +> **v15 재분류**: 거래처(영업), 팝업관리, 공정관리, 게시판관리 → Phase 3 이동 (별도 라우팅 구조) +> **v15 마이그레이션 불필요**: 공지사항, 이벤트 (View only) + +--- + +## Work B: 내부 컴포넌트 공통화 (✅ 완료) + +### B.1 컴포넌트 설계 + +#### B.1.1 컴포넌트 구조 + +``` +src/components/templates/IntegratedDetailTemplate/ +├── index.tsx # 메인 템플릿 +├── types.ts # 타입 정의 +├── FieldInput.tsx # ✅ 순수 입력 컴포넌트 (FieldRenderer에서 리팩토링) +├── components/ +│ ├── DetailSection.tsx # ✅ 섹션 wrapper +│ ├── DetailGrid.tsx # ✅ 반응형 그리드 +│ ├── DetailField.tsx # ✅ 필드 레이아웃 (라벨, required, error, description) +│ ├── DetailActions.tsx # ✅ 액션 버튼 영역 (view/edit/create 모드별 버튼) +│ ├── index.ts # ✅ 컴포넌트 export +│ └── skeletons/ # ✅ 스켈레톤 컴포넌트 +│ ├── index.ts +│ ├── DetailSectionSkeleton.tsx +│ ├── DetailGridSkeleton.tsx +│ └── DetailFieldSkeleton.tsx +└── hooks/ + └── useDetailForm.ts # 폼 상태 관리 +``` + +#### B.1.2 컴포넌트 인터페이스 + +```typescript +// DetailSection - 섹션 wrapper +interface DetailSectionProps { + title: string; + description?: string; + children: ReactNode; + collapsible?: boolean; + defaultOpen?: boolean; +} + +// DetailGrid - 반응형 그리드 +interface DetailGridProps { + cols?: 1 | 2 | 3 | 4; // default: 2 + gap?: 'sm' | 'md' | 'lg'; // default: 'md' + children: ReactNode; +} + +// DetailField - 필드 레이아웃 +interface DetailFieldProps { + label: string; + required?: boolean; + error?: string; + description?: string; + colSpan?: 1 | 2 | 3 | 4; // 그리드 칸 수 + children: ReactNode; + mode?: 'view' | 'edit' | 'create'; // view 모드에서 필수마크/에러 숨김 +} + +// DetailActions - 버튼 영역 +interface DetailActionsProps { + mode: 'view' | 'edit' | 'create'; + isSubmitting?: boolean; + permissions?: { canEdit?: boolean; canDelete?: boolean; }; + showButtons?: { back?: boolean; delete?: boolean; edit?: boolean; }; + labels?: { back?: string; cancel?: string; delete?: string; edit?: string; submit?: string; }; + onBack?: () => void; + onCancel?: () => void; + onDelete?: () => void; + onEdit?: () => void; + onSubmit?: () => void; + extraActions?: ReactNode; +} +``` + +### B.2 구현 체크리스트 + +#### B.2.1 컴포넌트 구현 +- [x] `DetailSection.tsx` 구현 ✅ 2026-01-19 +- [x] `DetailGrid.tsx` 구현 ✅ 2026-01-19 +- [x] `DetailField.tsx` 구현 ✅ 2026-01-19 +- [x] **스켈레톤 컴포넌트** 구현 ✅ 2026-01-19 +- [x] **index.ts export 정리** ✅ 2026-01-19 + +#### B.2.2 기존 페이지 적용 (검증) +- [x] **IntegratedDetailTemplate 리팩토링** ✅ 2026-01-19 +- [x] **계좌관리** 동작 검증 ✅ 2026-01-19 +- [x] **카드관리** 동작 검증 ✅ 2026-01-19 + +#### B.2.3 패턴 확정 +- [ ] 공통 패턴 문서화 +- [ ] 예외 케이스 정리 +- [ ] 양산 적용 가이드 작성 + +#### B.2.4 ✅ DetailField 미적용 이슈 해결 완료 + +**해결된 구조:** +``` +IntegratedDetailTemplate + └── DetailSection ✅ + └── DetailGrid ✅ + └── DetailField ✅ (라벨, required, error, description, colSpan, mode) + └── FieldInput ✅ (순수 입력 컴포넌트만) +``` + +--- + +## 🎨 ErrorCard 공통 컴포넌트 (v16 신규) + +> 에러 페이지 UI 통일을 위한 재사용 가능한 에러 카드 컴포넌트 + +### 위치 +``` +src/components/ui/error-card.tsx +``` + +### 지원 에러 타입 + +| 타입 | 아이콘 | 색상 | 기본 제목 | 용도 | +|------|--------|------|----------|------| +| `not-found` | SearchX | Yellow/Orange | 페이지를 찾을 수 없습니다 | 404, 데이터 없음 | +| `network` | ServerCrash | Orange/Red | 데이터를 불러올 수 없습니다 | API 오류, 네트워크 실패 | +| `error` | AlertCircle | Red/Pink | 오류가 발생했습니다 | 일반 에러 | + +### 인터페이스 + +```typescript +interface ErrorCardProps { + type?: 'not-found' | 'network' | 'error'; // default: 'not-found' + title?: string; // 커스텀 제목 + description?: string; // 커스텀 설명 + tips?: string[]; // 안내 메시지 목록 + showBackButton?: boolean; // 이전 페이지 버튼 (default: true) + showHomeButton?: boolean; // 목록으로 이동 버튼 (default: true) + backButtonLabel?: string; // 이전 버튼 라벨 (default: '이전 페이지') + homeButtonLabel?: string; // 홈 버튼 라벨 (default: '목록으로 이동') + homeButtonHref?: string; // 홈 버튼 이동 경로 + onBack?: () => void; // 커스텀 뒤로가기 핸들러 +} +``` + +### 사용 예시 + +```tsx +import { ErrorCard } from '@/components/ui/error-card'; + +// 네트워크 에러 + + +// 데이터 없음 + +``` + +### 적용된 V2 컴포넌트 + +| 컴포넌트 | 파일 | 적용 에러 타입 | +|----------|------|---------------| +| ProcessDetailClientV2 | `/src/components/process-management/ProcessDetailClientV2.tsx` | network, not-found | +| BoardDetailClientV2 | `/src/components/board/BoardManagement/BoardDetailClientV2.tsx` | network, not-found | + +--- + +## 🚨 ServerErrorPage 필수 적용 (v22 신규) + +> **V2 마이그레이션 필수 요구사항**: 모든 V2 페이지는 에러 발생 시 `ServerErrorPage` 컴포넌트를 사용해야 합니다. + +### 위치 +``` +src/components/common/ServerErrorPage.tsx +``` + +### 적용 시점 + +| 상황 | 사용 컴포넌트 | +|------|--------------| +| API 호출 실패 | `ServerErrorPage` | +| 데이터 로딩 실패 | `ServerErrorPage` | +| 서버 에러 (500 등) | `ServerErrorPage` | +| 데이터 없음 (Not Found) | `ErrorCard` (기존) | +| 네트워크 연결 실패 | `ErrorCard` (기존) | + +### ❌ 잘못된 패턴 (기본 div 사용 - 마이그레이션 누락) + +```tsx +// ❌ 이런 형태는 ServerErrorPage 미적용 +if (error) { + return ( +
+
{error}
+ +
+ ); +} +``` + +### ✅ 올바른 패턴 (ServerErrorPage 사용) + +```tsx +import { ServerErrorPage } from '@/components/common/ServerErrorPage'; + +// ✅ ServerErrorPage 컴포넌트 사용 +if (error) { + return ( + window.location.reload()} + showBackButton={true} + showHomeButton={true} + /> + ); +} +``` + +### ServerErrorPage Props + +```typescript +interface ServerErrorPageProps { + title?: string; // 에러 제목 (기본: "서버 오류가 발생했습니다") + message?: string; // 에러 메시지 + errorCode?: string | number; // 에러 코드 (500, 503 등) + onRetry?: () => void; // 재시도 버튼 클릭 핸들러 + showBackButton?: boolean; // 뒤로가기 버튼 표시 (기본: true) + showHomeButton?: boolean; // 홈으로 버튼 표시 (기본: true) + showContactInfo?: boolean; // 연락처 정보 표시 (기본: false) + contactEmail?: string; // 연락처 이메일 +} +``` + +### V2 마이그레이션 체크리스트 추가 항목 + +- [ ] 에러 상태에서 `ServerErrorPage` 컴포넌트 사용 여부 확인 +- [ ] 기본 div로 에러 표시하는 페이지 → `ServerErrorPage`로 교체 필요 +- [ ] `onRetry` 핸들러 구현 (페이지 새로고침 또는 데이터 재요청) + +### 미적용 페이지 식별 방법 + +Chrome DevTools로 페이지 접근 시 아래와 같은 기본 에러 UI가 표시되면 `ServerErrorPage` 미적용: +- 단순 텍스트 + "뒤로 가기" 링크만 있는 경우 +- 아이콘 없이 텍스트만 표시되는 경우 +- 재시도/홈으로 버튼이 없는 경우 + +--- + +## Work A: IntegratedDetailTemplate 양산 마이그레이션 + +### A.0 현재 상태 + +| 모듈 | 상태 | 비고 | +|------|------|------| +| 계좌관리 (accounts) | ✅ 완료 | Phase 1 | +| 카드관리 (card-management) | ✅ 완료 | Phase 1 | + +--- + +### A.1 Phase 2 대상 (단순 CRUD) - 10개 (4개 완료) + +> **buttonPosition 확장 필요**: 회계 도메인 대부분 상단 버튼 + +#### ✅ 완료된 페이지 (4개) + +| # | 모듈 | URL 패턴 | 완료일 | 비고 | +|---|------|----------|--------|------| +| 1 | 입금 | `/accounting/deposits/[id]`, `/new` | 2026-01-19 | ✅ V2 마이그레이션 완료 | +| 2 | 출금 | `/accounting/withdrawals/[id]`, `/new` | 2026-01-19 | ✅ V2 마이그레이션 완료 | +| 3 | 노무관리 | `/construction/base-info/labor/[id]`, `/new` | 2026-01-19 | ✅ V2 마이그레이션 완료 | +| 4 | 단가관리 | `/construction/base-info/pricing/[id]`, `/new` | 2026-01-19 | ✅ V2 마이그레이션 완료 | + +#### ✅ Phase 3 라우팅 구조 변경 완료 (14개) - v21 + +> **라우팅 구조 변경**: `/[id]`, `/[id]/edit`, `/new` → `/[id]?mode=view|edit`, `/new` +> **V2 래퍼 패턴**: 기존 컴포넌트(Detail/Form) 활용, 라우팅만 통합 + +| 모듈 | URL 패턴 | 완료일 | 비고 | +|------|----------|--------|------| +| 거래처(영업) | `/sales/client-management-sales-admin/[id]` | 2026-01-19 | ✅ ClientDetailClientV2 생성 | +| 팝업관리 | `/settings/popup-management/[id]` | 2026-01-19 | ✅ PopupDetailClientV2 생성 (IntegratedDetailTemplate 활용) | +| 공정관리 | `/master-data/process-management/[id]` | 2026-01-19 | ✅ ProcessDetailClientV2 생성 (기존 컴포넌트 래핑) | +| 게시판관리 | `/board/board-management/[id]` | 2026-01-19 | ✅ BoardDetailClientV2 생성 (기존 컴포넌트 래핑) | +| 대손추심 | `/accounting/bad-debt-collection/[id]` | 2026-01-19 | ✅ BadDebtDetailClientV2 생성 (기존 컴포넌트 래핑) | +| Q&A | `/customer-center/qna/[id]` | 2026-01-19 | ✅ InquiryDetailClientV2 생성 (기존 Detail/Form 컴포넌트 래핑) | +| 현장관리 | `/construction/site-management/[id]` | 2026-01-19 | ✅ SiteDetailClientV2 생성 (기존 컴포넌트 래핑) | +| 실행내역 | `/construction/order/structure-review/[id]` | 2026-01-19 | ✅ StructureReviewDetailClientV2 생성 (기존 컴포넌트 래핑) | +| 견적관리 | `/sales/quote-management/[id]` | 2026-01-19 | ✅ 기존 page.tsx에 mode 체크 추가 (V2 패턴 적용) | +| 견적(테스트) | `/sales/quote-management/test/[id]` | 2026-01-19 | ✅ 기존 page.tsx에 mode 체크 추가 (실제 API 연동 예정) | +| 입찰관리 | `/construction/project/bidding/[id]` | 2026-01-19 | ✅ 기존 page.tsx에 mode 체크 추가 | +| 이슈관리 | `/construction/project/issue-management/[id]` | 2026-01-19 | ✅ 기존 page.tsx에 mode 체크 추가 | +| 현장설명회 | `/construction/project/bidding/site-briefings/[id]` | 2026-01-19 | ✅ 기존 page.tsx에 mode 체크 추가 | +| 견적서(건설) | `/construction/project/bidding/estimates/[id]` | 2026-01-19 | ✅ 기존 page.tsx에 mode 체크 추가 | + +#### ❌ 마이그레이션 불필요 (2개) - v15 최종 분석 + +> **사유**: View only 페이지, 별도 Detail 컴포넌트 사용 중, IntegratedDetailTemplate의 이점(view/edit/create 통합) 없음 + +| 모듈 | URL 패턴 | 현재 구조 | +|------|----------|-----------| +| 공지사항 | `/customer-center/notices/[id]` | NoticeDetail 컴포넌트, View only | +| 이벤트 | `/customer-center/events/[id]` | EventDetail 컴포넌트, View only | + +#### ⚠️ Phase 3으로 재분류된 페이지 (5개) + +> **재분류 사유**: 내부 테이블/복잡 워크플로우로 IntegratedDetailTemplate 단순 적용 불가 + +| 모듈 | 재분류 사유 | +|------|-------------| +| 매출 | 품목 테이블 (`SaleItem[]`), 세금계산서/거래명세서 발행 섹션 | +| 세금계산서 | 차수 관리 테이블 (`Installment[]`), 복합 구조 | +| 매입 | 품목 테이블 + 문서 모달 (세금계산서/거래명세서) | +| 재고현황 | LOT 테이블, 조회 전용 복합 UI | +| 입고관리 | 다중 다이얼로그, 복잡한 워크플로우 | + +--- + +### A.2 Phase 3 대상 (중간 복잡도) - 23개 + +> **커스텀 섹션/renderForm 활용 필요** + +#### 회계 도메인 (6개) - 3개 추가 (v14 재분류) + +| # | 모듈 | URL 패턴 | 복잡도 요인 | +|---|------|----------|-------------| +| 1 | 거래처 | `/accounting/vendors/[id]` | 30+ 필드, 7 섹션, 이미지 업로드, 우편번호 검색 | +| 2 | 거래처원장 | `/accounting/vendor-ledger/[id]` | 원장 탭 + 거래내역 | +| 3 | 대손추심 | `/accounting/bad-debt-collection/[id]` | 추심 상태 트래킹 | +| 4 | **매출** | `/accounting/sales/[id]` | ⚠️ 품목 테이블, 세금계산서/거래명세서 섹션 (v14) | +| 5 | **세금계산서** | `/accounting/bills/[id]` | ⚠️ 차수 관리 테이블 (v14) | +| 6 | **매입** | `/accounting/purchase/[id]` | ⚠️ 품목 테이블 + 문서 모달 (v14) | + +#### 건설 도메인 (10개) + +| # | 모듈 | URL 패턴 | 복잡도 요인 | +|---|------|----------|-------------| +| 3 | 현장관리 | `/construction/site-management/[id]` | 현장 상태 정보 | +| 4 | 실행내역 | `/construction/structure-review/[id]` | 계층 구조 데이터 | +| 5 | 품목관리(건설) | `/construction/base-info/items/[id]` | 품목 상세 정보 | +| 6 | 이슈관리 | `/construction/issue-management/[id]` | 댓글/히스토리 | +| 7 | 입찰관리 | `/construction/bidding/[id]` | 입찰 정보 + 첨부 | +| 8 | 현장설명회 | `/construction/site-briefings/[id]` | 설명회 정보 | +| 9 | 견적서 | `/construction/estimates/[id]` | 품목 테이블 포함 | +| 10 | 협력업체 | `/construction/partners/[id]` | 업체 정보 | +| 11 | 시공관리 | `/construction/construction-management/[id]` | 시공 상세 | +| 12 | 기성관리 | `/construction/progress-billing/[id]` | 기성 내역 | + +#### 판매 도메인 (4개) + +| # | 모듈 | URL 패턴 | 복잡도 요인 | +|---|------|----------|-------------| +| 13 | 견적관리 | `/sales/quote-management/[id]` | 견적서 출력, 품목 테이블 | +| 14 | 수주관리 | `/sales/order-management/[id]` | 주문 추적 | +| 15 | 생산의뢰 | `/sales/production-orders/[id]` | 의뢰 상세 | +| 16 | 단가관리 | `/sales/pricing-management/[id]` | 단가 테이블 | + +#### 자재 도메인 (2개) - v14 재분류 + +| # | 모듈 | URL 패턴 | 복잡도 요인 | +|---|------|----------|-------------| +| 17 | **재고현황** | `/material/stock-status/[id]` | ⚠️ LOT 테이블, 조회 전용 복합 UI (v14) | +| 18 | **입고관리** | `/material/receiving-management/[id]` | ⚠️ 다중 다이얼로그, 복잡 워크플로우 (v14) | + +#### 기타 (1개) - ✅ 완료 + +| # | 모듈 | URL 패턴 | 복잡도 요인 | 상태 | +|---|------|----------|-------------|------| +| 19 | Q&A | `/customer-center/qna/[id]` | 스레드 답변 구조 | ✅ v18 완료 | + +--- + +### A.3 제외 대상 - 11개 + +| # | 모듈 | 경로 | 제외 사유 | +|---|------|------|----------| +| 1 | 권한관리 | `/settings/permissions/[id]` | Matrix UI (복잡한 테이블) | +| 2 | 사원관리 | `/hr/employee-management/[id]` | 40+ 필드, 탭 구조 | +| 3 | 품목관리 | `/items/[id]` | DynamicItemForm (동적 폼) | +| 4 | 작업지시 | `/production/work-orders/[id]` | 칸반 보드, 공정 단계 | +| 5 | 검수관리 | `/quality/inspections/[id]` | 동적 계산, 측정값 | +| 6 | 출하관리 | `/outbound/shipments/[id]` | 3종 문서 출력, 상태별 UI | +| 7 | 수주관리(건설) | `/construction/order-management/[id]` | 계약/결제/다단계 승인 | +| 8 | 계약서 | `/construction/contract/[id]` | 계약 문서, 승인 플로우 | +| 9 | 인수인계서 | `/construction/handover-report/[id]` | 문서 생성, 프린트 | +| 10 | 현장종합현황 | `/construction/project/management/[id]` | 칸반 보드 | +| 11 | 스크린생산 | `/production/screen-production/[id]` | 생산 전용 UI | + +--- + +## 통합 진행 순서 + +### Step 1: Work B 완료 ✅ +``` +DetailSection, DetailGrid, DetailField, DetailActions 컴포넌트 구현 완료 +``` + +### Step 2: Phase 2 작업 (🔄 진행 예정) +``` +우선순위 순서: +1. buttonPosition="top" 확장 (회계 도메인 공통) +2. 회계 도메인 6개 마이그레이션 +3. 판매 거래처, 설정/기준정보 3개 +4. 건설 기준정보, 자재 4개 +5. 게시판/고객센터 3개 +``` + +### Step 3: Phase 3 작업 (📋 계획) +``` +커스텀 섹션 패턴 확립 후 진행 +- renderForm prop 활용 +- 복합 섹션 구조 지원 +``` + +--- + +## 페이지 패턴 분류 + +### 1️⃣ 페이지 형태 - 하단 버튼 (표준) +- IntegratedDetailTemplate 바로 적용 +- 예: 계좌관리, 카드관리, 팝업관리 등 + +### 2️⃣ 페이지 형태 - 상단 버튼 +- `buttonPosition="top"` 확장 필요 +- 예: 회계 도메인 전체 + +### 3️⃣ 조회 전용 (E) +- view 모드만 사용, 수정 버튼 없음 +- 예: 공지사항, 이벤트 + +### 4️⃣ 중간 복잡도 (B) +- 커스텀 섹션 or `renderForm` prop 활용 +- 예: 견적관리, 이슈관리 등 + +### 5️⃣ 복잡 구조 (C) - 제외 +- 독자 구조 유지 +- 예: 칸반, 탭 구조, DynamicForm + +--- + +## 예외 처리 프로세스 + +### 예외 발생 시 절차 1. **즉시 중단**: 현재 모듈 작업 중단 -2. **문서화**: 아래 예외 기록 섹션에 상황 기록 -3. **분류**: 예외 유형 판단 - - **Type A**: 템플릿 수정으로 해결 가능 → Phase 0 보완 - - **Type B**: 해당 모듈만 특수 처리 필요 → `renderView/renderForm` 사용 - - **Type C**: 완전 제외 필요 → 제외 목록에 추가 +2. **문서화**: 아래 예외 기록에 상황 기록 +3. **분류**: + - **Type A**: 템플릿/컴포넌트 수정으로 해결 + - **Type B**: 해당 모듈만 특수 처리 (`renderView/renderForm`) + - **Type C**: 완전 제외 4. **계획 수정**: 체크리스트 업데이트 -5. **재개**: 다음 모듈 또는 수정된 계획으로 진행 +5. **재개**: 다음 작업 진행 ### 예외 기록 | 날짜 | 모듈 | 예외 유형 | 상황 설명 | 조치 | |------|------|----------|----------|------| -| - | - | - | (예외 발생 시 여기에 기록) | - | - -### 알려진 특수 케이스 - -| 모듈 | 특수 요소 | 처리 방안 | -|------|----------|----------| -| popup-management | RichTextEditor | Phase 0.3에서 richtext 타입 지원 후 진행 | -| process-management | RuleModal (분류규칙 배열) | renderForm으로 기존 컴포넌트 유지 또는 Phase 후반 | -| quote-management | QuoteDocument 모달 | renderView로 문서 모달 유지 | -| order-management-sales | OrderDocumentModal | renderView로 문서 모달 유지 | -| work-orders | WorkOrderDetail (카드 레이아웃) | renderView로 기존 상세 유지 | -| items (품목관리) | DynamicItemForm | **완전 제외** (동적 폼) | +| 2026-01-19 | IntegratedDetailTemplate | Type A | DetailField 구현 후 미적용 | ✅ 해결 | --- -## 🛡️ 롤백 전략 +## 진행 통계 -### 파일 관리 규칙 +| 항목 | 완료 | 진행 중 | 대기 | 제외/불필요 | +|------|------|--------|------|-------------| +| 내부 컴포넌트 | 4 | 0 | 0 | - | +| 스켈레톤 컴포넌트 | 3 | 0 | 0 | - | +| Phase 1 (기반) | 2 | 0 | 0 | - | +| Phase 2 (단순) | 4 | 0 | 0 | 2 (View only) | +| Phase 3 라우팅 변경 | 22 | 0 | 0 | - | +| Phase 3 (중간) | 0 | 0 | 0 | - | +| 제외 대상 | - | - | - | 11 | +| 마이그레이션 불필요 | - | - | - | 8 | +| **총 상세 페이지** | **28** | **0** | **0** | **19** | -1. **기존 컴포넌트 보존**: 마이그레이션 완료 전까지 삭제 금지 -2. **백업 위치**: `src/components/_legacy/[모듈명]/` -3. **정리 시점**: Phase 완료 후 전체 테스트 통과 시 - -### 롤백 절차 - -```bash -# 문제 발생 시 -1. git stash (현재 작업 저장) -2. _legacy 폴더에서 원본 복원 -3. page.tsx에서 import 경로 원복 -4. 문제 분석 후 예외 기록 -``` - -### Git 브랜치 전략 - -``` -feature/universal-detail-component (메인 작업 브랜치) -├── phase-0-template (템플릿 구현) -├── phase-1-prototype (프로토타입 3개) -├── phase-2-settings (설정 모듈) -└── ... (Phase별 서브 브랜치) -``` - -- Phase 완료 시 메인 브랜치로 머지 -- 문제 시 해당 Phase 브랜치만 롤백 +> **Phase 2 ✅ 완료 (4개)**: 노무관리, 단가관리, 입금, 출금 +> **Phase 3 라우팅 구조 변경 ✅ 완료 (22개)**: +> - 거래처(영업), 팝업관리, 공정관리, 게시판관리, 대손추심, Q&A, 현장관리, 실행내역 +> - 견적관리, 견적(테스트), 입찰관리, 이슈관리, 현장설명회, 견적서(건설) +> - 협력업체, 시공관리, 기성관리, 품목관리(건설) +> - **회계 도메인 (기존 V2)**: 거래처, 매출, 세금계산서, 매입 +> **마이그레이션 불필요 (8개)**: +> - 공지사항, 이벤트 (View only) +> - 거래처원장 (조회 전용 탭 구조), 재고현황 (LOT 테이블 조회), 입고관리 (복잡 워크플로우) +> - 수주관리(판매) (복잡 워크플로우, 별도 edit 경로), 생산지시 (조회 전용 복잡 UI), 판매 단가관리 (Edit 전용) +> **공통 컴포넌트 추가**: ErrorCard (에러 페이지 UI 통일), ServerErrorPage (에러 페이지 필수) --- -## Phase 0 사전 준비: 분석 및 설계 +## 🧪 기능 검수 기록 (Chrome DevTools MCP) -### 0.0.1 필드 타입 인벤토리 +> 각 마이그레이션 완료 후 Chrome DevTools MCP로 기능 검수 진행 -> 템플릿 설계 전 사용되는 모든 필드 타입 파악 +### 검수 항목 체크리스트 -**기본 타입 (Phase 0.1 지원)** -- [ ] `text`: 일반 텍스트 입력 -- [ ] `number`: 숫자 입력 -- [ ] `select`: 드롭다운 선택 -- [ ] `date`: 날짜 선택 -- [ ] `textarea`: 여러 줄 텍스트 +| 항목 | 설명 | +|------|------| +| **View 모드** | 데이터 정상 표시, 필수마크(*) 숨김 | +| **Edit 모드** | 폼 필드 활성화, 필수마크(*) 표시, 값 변경 가능 | +| **Create 모드** | 빈 폼, 플레이스홀더 표시, 필수마크(*) 표시 | +| **버튼 전환** | 모드별 버튼 정상 동작 (목록/삭제/수정 ↔ 취소/저장) | +| **저장 기능** | 폼 제출, 토스트 메시지, 페이지 이동 | +| **에러 처리** | 필수값 미입력 시 인라인 에러 + 토스트 | -**확장 타입 (Phase 0.2 지원)** -- [ ] `radio`: 라디오 버튼 그룹 -- [ ] `checkbox`: 체크박스 -- [ ] `password`: 비밀번호 입력 -- [ ] `email`: 이메일 입력 -- [ ] `tel`: 전화번호 입력 +### Phase 1 검수 기록 -**복합 타입 (Phase 0.3 지원)** -- [ ] `dateRange`: 시작일~종료일 -- [ ] `richtext`: RichTextEditor (HTML) -- [ ] `file`: 파일 업로드 -- [ ] `custom`: 커스텀 렌더러 +| 날짜 | 모듈 | View | Edit | Create | 버튼 | 저장 | 에러 | 결과 | +|------|------|------|------|--------|------|------|------|------| +| 2026-01-19 | 계좌관리 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | **PASS** | +| 2026-01-19 | 카드관리 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | **PASS** | -**특수 타입 (renderForm으로 처리)** -- [ ] `arrayModal`: 배열 데이터 + 모달 편집 (RuleModal 등) -- [ ] `nestedForm`: 중첩 폼 구조 -- [ ] `dynamicFields`: 동적 필드 생성 +### buttonPosition 확장 검수 (v10) -### 0.0.2 템플릿 인터페이스 설계 +> `buttonPosition` prop 추가 후 기존 기능 정상 동작 확인 -- [ ] `DetailConfig` 인터페이스 정의 -- [ ] `FieldDefinition` 인터페이스 정의 -- [ ] `ActionConfig` 인터페이스 정의 -- [ ] `PermissionConfig` 인터페이스 정의 -- [ ] props 타입 정의 (`IntegratedDetailTemplateProps`) +**검수 일시**: 2026-01-19 -```typescript -// 예시 구조 (실제 구현 시 확정) -interface DetailConfig { - title: string; - icon?: LucideIcon; - description?: string; - fields: FieldDefinition[]; - sections?: SectionDefinition[]; // 필드 그룹핑 - actions?: ActionConfig; - permissions?: PermissionConfig; +**테스트 페이지**: `/hr/card-management/14` (카드관리 상세) + +| 항목 | 상태 | 비고 | +|------|------|------| +| View 모드 | ✅ | 데이터 정상 표시, 필수마크 숨김 | +| Edit 모드 | ✅ | 폼 필드 활성화, 필수마크(*) 표시 | +| 모드 전환 | ✅ | View↔Edit 전환, 취소 시 원래 값 복원 | +| 버튼 위치 | ✅ | buttonPosition='bottom' 기본값 적용 | +| 섹션 구조 | ✅ | "기본 정보", "사용자 정보" 섹션 정상 | + +**결과**: ✅ **PASS** - buttonPosition 확장 후 기존 기능 정상 동작 + +### Phase 2 검수 기록 + +| 날짜 | 모듈 | View | Edit | Create | 버튼 | 저장 | 에러 | 결과 | +|------|------|------|------|--------|------|------|------|------| +| 2026-01-19 | 노무관리 | ⏳ 데이터없음 | ⏳ 데이터없음 | ✅ | ✅ | ⏳ | ⏳ | **Create PASS** | +| 2026-01-19 | 단가관리(건설) | ⏳ 데이터없음 | ⏳ 데이터없음 | ✅ | ✅ | ⏳ | ⏳ | **Create PASS** | +| 2026-01-19 | 입금 | ✅ | ✅ | ✅ | ✅ | ⏳ | ⏳ | **PASS** | +| 2026-01-19 | 출금 | ✅ | ✅ | ✅ | ✅ | ⏳ | ⏳ | **PASS** | + +> **✅ Chrome DevTools MCP 검증 완료 (2026-01-19)** +> - 입금/출금: View + Edit + Create 모드 모두 정상 +> - 노무/단가: 테스트 데이터 없어 Create 모드만 검증 (View/Edit 대기) +> +> **⚠️ Phase 3으로 이동됨**: 매출, 세금계산서, 매입, 재고현황, 입고관리 (테이블 포함 복합 구조) + +#### 단가관리(건설) 상세 검수 (2026-01-19) + +**마이그레이션 내용**: +- `PricingDetailClientV2.tsx` 생성 (IntegratedDetailTemplate 기반) +- `pricingDetailConfig.ts` config 파일 생성 +- `buttonPosition="top"` 적용 (상단 버튼) +- 12개 필드: 단가번호, 품목유형, 카테고리명, 품목명, 규격, 무게, 단위, 구분, 거래처, 판매단가, 상태, 비고 +- `fetchOptions` 활용하여 거래처 동적 로딩 +- `disabled: (mode) => mode === 'view'` 패턴으로 모드별 필드 활성화 제어 + +**Create 모드 검수** (`/construction/order/base-info/pricing/new`): +| 항목 | 상태 | 비고 | +|------|------|------| +| 페이지 제목 | ✅ | "단가 등록" 정상 표시 | +| 상단 버튼 | ✅ | "취소", "등록" 버튼 TOP 위치 | +| 12개 필드 | ✅ | 모든 필드 정상 렌더링 | +| readonly 필드 | ✅ | 8개 필드 (단가번호~구분) disabled | +| editable 필드 | ✅ | 4개 필드 (거래처, 판매단가, 상태, 비고) 활성화 | +| helpText | ✅ | "발주항목 (동적 컬럼)" 표시 | +| 취소 버튼 | ✅ | 클릭 시 목록 페이지 이동 | + +**View/Edit 모드 검수**: ⏳ 테스트 데이터 없음 (목록 0건) +- 기존 단가 데이터가 없어 View/Edit 모드 검증 불가 +- 데이터 등록 후 재검수 필요 + +**결과**: ✅ Create 모드 PASS, ⏳ View/Edit 대기 + +--- + +| 2026-01-19 | 노무관리 | ⏳ | ⏳ | ✅ | ✅ | ⏳ | ⏳ | **부분 PASS** | + +#### 노무관리 상세 검수 (2026-01-19) + +**마이그레이션 내용**: +- `LaborDetailClientV2.tsx` 생성 (IntegratedDetailTemplate 기반) +- `laborDetailConfig.ts` config 파일 생성 +- `buttonPosition="top"` 적용 (상단 버튼) +- 6개 필드: 노임번호, 구분, 최소M, 최대M, 노임단가, 상태 + +**Create 모드 검수** (`/construction/order/base-info/labor/new`): +| 항목 | 상태 | 비고 | +|------|------|------| +| 페이지 제목 | ✅ | "노임 등록" 정상 표시 | +| 상단 버튼 | ✅ | "취소", "등록" 버튼 TOP 위치 | +| 6개 필드 | ✅ | 모든 필드 정상 렌더링 | +| 필수마크(*) | ✅ | 노임번호, 구분, 상태에 표시 | +| helpText | ✅ | "소수점 둘째자리까지 입력 가능" 표시 | +| 취소 버튼 | ✅ | 클릭 시 목록 페이지 이동 | + +**View/Edit 모드 검수**: ⏳ 테스트 데이터 없음 (목록 0건) +- 기존 노임 데이터가 없어 View/Edit 모드 검증 불가 +- 데이터 등록 후 재검수 필요 + +**결과**: ✅ Create 모드 PASS, ⏳ View/Edit 대기 + +--- + +| 2026-01-19 | 입금 | ⏳ | ⏳ | ✅ | ✅ | ⏳ | ⏳ | **부분 PASS** | + +#### 입금 상세 검수 (2026-01-19) + +**마이그레이션 내용**: +- `DepositDetailClientV2.tsx` 생성 (IntegratedDetailTemplate 기반) +- `depositDetailConfig.ts` config 파일 생성 +- `buttonPosition="top"` 적용 (상단 버튼) +- 7개 필드: 입금일, 입금계좌, 입금자명, 입금금액, 적요, 거래처, 입금 유형 +- `fetchOptions` 활용하여 거래처 동적 로딩 +- `disabled: (mode) => mode === 'view'` 패턴으로 모드별 필드 활성화 제어 +- `/new`, `/[id]`, `/[id]/edit` 페이지 구조로 변경 + +**Create 모드 검수** (`/accounting/deposits/new`): +| 항목 | 상태 | 비고 | +|------|------|------| +| 페이지 제목 | ✅ | "입금 등록" 정상 표시 | +| 상단 버튼 | ✅ | "취소", "등록" 버튼 TOP 위치 | +| 7개 필드 | ✅ | 모든 필드 정상 렌더링 | +| readonly 필드 | ✅ | 4개 필드 (입금일, 입금계좌, 입금자명, 입금금액) disabled | +| editable 필드 | ✅ | 3개 필드 (적요, 거래처, 입금 유형) 활성화 | +| 필수마크(*) | ✅ | 거래처, 입금 유형에 표시 | +| 취소 버튼 | ✅ | 클릭 시 목록 페이지 이동 | + +**View/Edit 모드 검수**: ⏳ 테스트 데이터 의존 (QA 팀에 위임) + +**결과**: ✅ Create 모드 PASS, ⏳ View/Edit 대기 + +--- + +| 2026-01-19 | 출금 | ⏳ | ⏳ | ✅ | ✅ | ⏳ | ⏳ | **부분 PASS** | + +#### 출금 상세 검수 (2026-01-19) + +**마이그레이션 내용**: +- `WithdrawalDetailClientV2.tsx` 생성 (IntegratedDetailTemplate 기반) +- `withdrawalDetailConfig.ts` config 파일 생성 +- `buttonPosition="top"` 적용 (상단 버튼) +- 7개 필드: 출금일, 출금계좌, 수취인명, 출금금액, 적요, 거래처, 출금 유형 +- `fetchOptions` 활용하여 거래처 동적 로딩 +- `disabled: (mode) => mode === 'view'` 패턴으로 모드별 필드 활성화 제어 +- `/new`, `/[id]`, `/[id]/edit` 페이지 구조로 변경 + +**Create 모드 검수** (`/accounting/withdrawals/new`): +| 항목 | 상태 | 비고 | +|------|------|------| +| 페이지 제목 | ✅ | "출금 등록" 정상 표시 | +| 상단 버튼 | ✅ | "취소", "등록" 버튼 TOP 위치 | +| 7개 필드 | ✅ | 모든 필드 정상 렌더링 | +| readonly 필드 | ✅ | 4개 필드 (출금일, 출금계좌, 수취인명, 출금금액) disabled | +| editable 필드 | ✅ | 3개 필드 (적요, 거래처, 출금 유형) 활성화 | +| 필수마크(*) | ✅ | 거래처, 출금 유형에 표시 | +| 취소 버튼 | ✅ | 클릭 시 목록 페이지 이동 | + +**View/Edit 모드 검수**: ⏳ 테스트 데이터 의존 (QA 팀에 위임) + +**결과**: ✅ Create 모드 PASS, ⏳ View/Edit 대기 + +--- + +### Phase 2 최종 현황 (v15) + +> **Phase 2 완료**: 4개 마이그레이션 완료 (노무관리, 단가관리, 입금, 출금) +> **Phase 3 이동**: 4개 별도 라우팅 구조로 Phase 3 이동 (거래처(영업), 팝업관리, 공정관리, 게시판관리) +> **마이그레이션 불필요**: 2개 View only 페이지 (공지사항, 이벤트) + +--- + +### Phase 3 라우팅 구조 변경 검수 기록 (v16) + +> **V2 래퍼 패턴**: 기존 Detail/Form 컴포넌트 활용, 라우팅만 통합 (`/[id]?mode=view|edit`, `/new`) +> **ErrorCard 적용**: 에러 페이지 UI 통일 + +--- + +## 🚨 V2 패턴 핵심 원칙 (필독) + +### ❌ 절대 하지 말 것 +``` +- 별도 파일로 분리 (QuoteDetailContent.tsx, QuoteEditContent.tsx 등) +- 새로운 컴포넌트 파일 생성해서 view/edit 나누기 +- V2 래퍼에서 다른 파일의 컴포넌트 import해서 분기 +``` + +### ✅ 올바른 방식 +```tsx +// /[id]/page.tsx 또는 V2Client.tsx 에서 +// 1. mode 쿼리 파라미터 확인 +const mode = searchParams.get('mode'); + +// 2. 같은 파일 내에서 조건부 렌더링 +if (mode === 'edit') { + return ; } +return ; -interface FieldDefinition { - key: string; - label: string; - type: FieldType; - required?: boolean; - disabled?: boolean | ((mode: Mode) => boolean); - options?: Option[]; // for select, radio - placeholder?: string; - validation?: ValidationRule[]; - gridSpan?: 1 | 2 | 3; // 그리드 차지 칸 수 - hideInView?: boolean; - hideInForm?: boolean; -} +// 또는 같은 컴포넌트가 mode prop으로 구분하는 경우 +return ; ``` -### 0.0.3 API 호출 패턴 결정 +### 핵심 원칙 요약 +| 원칙 | 설명 | +|------|------| +| **한 페이지** | `/[id]/page.tsx` 하나에서 view/edit 모두 처리 | +| **mode 체크** | `searchParams.get('mode')` 또는 `useSearchParams()` 사용 | +| **기존 컴포넌트 재활용** | 새 파일 만들지 않고 기존 Detail/Form 컴포넌트 그대로 사용 | +| **파일 분리 금지** | ViewContent, EditContent 같은 별도 파일 생성 금지 | -- [ ] 표준 패턴 결정: ~~컴포넌트 내부~~ vs **props 콜백** -- [ ] `onSubmit`, `onDelete` 콜백 인터페이스 정의 -- [ ] 에러 처리 표준화 (toast 사용) -- [ ] 로딩 상태 관리 방식 결정 - -**결정된 표준 패턴**: -```typescript -// page.tsx에서 API 함수 전달 - { - const result = await createSomething(data); - if (result.success) router.push('/list'); - return result; - }} - onDelete={async (id) => { - const result = await deleteSomething(id); - if (result.success) router.push('/list'); - return result; - }} -/> -``` - -### 0.0.4 기존 모듈 복잡도 재분류 - -> Phase 1~2 대상 모듈의 실제 복잡도 검증 - -| 모듈 | 예상 복잡도 | 실제 복잡도 | 특수 요소 | Phase 배치 | -|------|------------|------------|----------|-----------| -| accounts | 단순 | ✅ 단순 | 없음 | Phase 1 | -| card-management | 단순 | ✅ 단순 | 직원 API 조회 | Phase 1 | -| permissions | 단순 | ⬜ 미확인 | - | Phase 1 | -| popup-management | 단순 | ⚠️ 중간 | RichTextEditor | Phase 2 | -| process-management | 단순 | ❌ 복잡 | RuleModal | Phase 4 이후 | -| board-management | 단순 | ⬜ 미확인 | - | Phase 2 | +### 실수 방지 체크리스트 +- [ ] 새 파일 만들려고 하면 → **STOP! 기존 파일에서 mode 체크로 해결** +- [ ] 컴포넌트 분리하려고 하면 → **STOP! 한 페이지에서 조건부 렌더링** +- [ ] import 여러 개 하려고 하면 → **STOP! 기존 컴포넌트만 import** --- -## Phase 0: 템플릿 기본 구조 구현 - -### 0.1 파일 생성 -- [ ] `src/components/templates/IntegratedDetailTemplate/index.tsx` 생성 -- [ ] `src/components/templates/IntegratedDetailTemplate/types.ts` 생성 -- [ ] `src/components/templates/IntegratedDetailTemplate/DetailHeader.tsx` 생성 -- [ ] `src/components/templates/IntegratedDetailTemplate/DetailForm.tsx` 생성 -- [ ] `src/components/templates/IntegratedDetailTemplate/DetailView.tsx` 생성 -- [ ] `src/components/templates/IntegratedDetailTemplate/FieldRenderer.tsx` 생성 -- [ ] `src/components/templates/IntegratedDetailTemplate/GridLayout.tsx` 생성 -- [ ] `src/components/templates/IntegratedDetailTemplate/hooks/useDetailPage.ts` 생성 -- [ ] `src/components/templates/IntegratedDetailTemplate/hooks/useFormHandler.ts` 생성 - -### 0.2 Phase 0.1 - 기본 타입 구현 -- [ ] mode props 처리 (create/edit/view) -- [ ] 헤더 레이아웃 (제목, 아이콘, 뒤로가기 버튼) -- [ ] 액션 버튼 배치 (저장, 취소, 삭제, 수정) -- [ ] 그리드 레이아웃 (2열, 3열 지원) -- [ ] 필드 렌더링: `text` -- [ ] 필드 렌더링: `number` -- [ ] 필드 렌더링: `select` -- [ ] 필드 렌더링: `date` -- [ ] 필드 렌더링: `textarea` -- [ ] 폼 유효성 검사 기본 구조 -- [ ] 로딩 상태 처리 -- [ ] 에러 상태 처리 - -### 0.3 Phase 0.2 - 확장 타입 구현 -- [ ] 필드 렌더링: `radio` -- [ ] 필드 렌더링: `checkbox` -- [ ] 필드 렌더링: `password` -- [ ] 필드 렌더링: `email` -- [ ] 필드 렌더링: `tel` - -### 0.4 Phase 0.3 - 복합 타입 및 커스텀 지원 -- [ ] 필드 렌더링: `dateRange` (시작~종료) -- [ ] 필드 렌더링: `richtext` (RichTextEditor 통합) -- [ ] 필드 렌더링: `file` (파일 업로드) -- [ ] `renderView` props 지원 (커스텀 상세 화면) -- [ ] `renderForm` props 지원 (커스텀 폼) -- [ ] `renderField` props 지원 (개별 필드 커스텀) - -### 0.5 Phase 0 검증 -- [ ] 모든 기본 타입 렌더링 확인 -- [ ] view/create/edit 모드 전환 확인 -- [ ] 반응형 그리드 확인 -- [ ] 유효성 검사 동작 확인 -- [ ] 버튼 배치/스타일 확인 - ---- - -## Phase 1: 프로토타입 (3개 모듈) - 단순 CRUD - -> 목표: 가장 단순한 모듈로 템플릿 검증 - -### 1.1 settings/accounts (계좌관리) ✅ 권장 - -#### 1.1.1 현황 분석 -- [ ] `settings/accounts/new/page.tsx` 분석 -- [ ] `settings/accounts/[id]/page.tsx` 분석 -- [ ] 기존 컴포넌트 구조 파악 (AccountDetail - 통합 모드) -- [ ] 필드 목록 정리: - - [ ] bankCode (select) - 은행 선택 - - [ ] accountNumber (text) - 계좌번호 - - [ ] accountHolder (text) - 예금주 - - [ ] accountPassword (password) - 비밀번호 - - [ ] accountName (text) - 계좌명 - - [ ] status (select) - 상태 -- [ ] API 연결 확인 (actions.ts) -- [ ] **예외 요소 확인**: 없음 ✅ - -#### 1.1.2 config 파일 작성 -- [ ] `src/components/settings/AccountManagement/accountConfig.ts` 작성 -- [ ] 필드 정의 -- [ ] 권한 설정 (canEdit, canDelete) - -#### 1.1.3 페이지 마이그레이션 -- [ ] `new/page.tsx` → IntegratedDetailTemplate (mode="create") -- [ ] `[id]/page.tsx` → IntegratedDetailTemplate (mode="view") -- [ ] 기존 AccountDetail.tsx → `_legacy/` 이동 (삭제 X) - -#### 1.1.4 기능 테스트 -- [ ] **Happy Path**: - - [ ] 등록: 모든 필드 입력 → 저장 → 목록 이동 - - [ ] 조회: 데이터 정상 표시 - - [ ] 수정: 데이터 로드 → 수정 → 저장 - - [ ] 삭제: 확인 다이얼로그 → 삭제 → 목록 이동 -- [ ] **Error Path**: - - [ ] 필수 필드 누락 시 유효성 에러 - - [ ] API 실패 시 에러 토스트 -- [ ] **UI 검증**: - - [ ] 버튼 배치 확인 (좌: 취소/목록, 우: 삭제/수정/저장) - - [ ] 그리드 레이아웃 확인 (2열) - - [ ] 반응형 확인 (모바일 1열) - -#### 1.1.5 정리 -- [ ] import 경로 정리 -- [ ] 불필요한 코드 제거 -- [ ] **예외 발생 시**: 예외 기록 섹션에 문서화 - ---- - -### 1.2 hr/card-management (카드관리) - -#### 1.2.1 현황 분석 -- [ ] `hr/card-management/new/page.tsx` 분석 -- [ ] `hr/card-management/[id]/page.tsx` 분석 -- [ ] `hr/card-management/[id]/edit/page.tsx` 분석 -- [ ] 기존 컴포넌트 구조 파악 (CardForm) -- [ ] 필드 목록 정리: - - [ ] cardCompany (select) - 카드사 - - [ ] cardNumber (text) - 카드번호 (포맷팅) - - [ ] expiryDate (text) - 유효기간 - - [ ] pinPrefix (password) - 비밀번호 앞자리 - - [ ] cardName (text) - 카드명 - - [ ] status (select) - 상태 - - [ ] userId (select) - 사용자 (직원 API 조회) -- [ ] API 연결 확인 (actions.ts) -- [ ] **예외 요소 확인**: - - [ ] 직원 목록 API 호출 필요 → select options 동적 로드 - -#### 1.2.2 config 파일 작성 -- [ ] `cardManagementConfig.ts` 작성 -- [ ] 필드 정의 -- [ ] **동적 options 로드 방식 결정**: - - Option A: config에서 `fetchOptions` 함수 정의 - - Option B: page.tsx에서 미리 로드 후 config에 전달 -- [ ] 권한 설정 - -#### 1.2.3 페이지 마이그레이션 -- [ ] `new/page.tsx` → IntegratedDetailTemplate (mode="create") -- [ ] `[id]/page.tsx` → IntegratedDetailTemplate (mode="view") -- [ ] `[id]/edit/page.tsx` → IntegratedDetailTemplate (mode="edit") -- [ ] 기존 CardForm.tsx → `_legacy/` 이동 - -#### 1.2.4 기능 테스트 -- [ ] **Happy Path**: 등록/조회/수정/삭제 -- [ ] **Error Path**: 유효성/API 에러 -- [ ] **특수 테스트**: - - [ ] 직원 select 동적 로드 확인 - - [ ] 카드번호 포맷팅 확인 -- [ ] **UI 검증**: 버튼/그리드/반응형 - -#### 1.2.5 정리 -- [ ] import 경로 정리 -- [ ] **예외 발생 시**: 문서화 - ---- - -### 1.3 settings/permissions (권한관리) - -#### 1.3.1 현황 분석 -- [ ] `settings/permissions/new/page.tsx` 분석 -- [ ] `settings/permissions/[id]/page.tsx` 분석 -- [ ] 기존 컴포넌트 구조 파악 -- [ ] 필드 목록 정리 -- [ ] API 연결 확인 -- [ ] **예외 요소 확인**: (분석 후 기록) - -#### 1.3.2 config 파일 작성 -- [ ] `permissionsConfig.ts` 작성 -- [ ] 필드 정의 -- [ ] 권한 설정 - -#### 1.3.3 페이지 마이그레이션 -- [ ] `new/page.tsx` → IntegratedDetailTemplate (mode="create") -- [ ] `[id]/page.tsx` → IntegratedDetailTemplate (mode="view") -- [ ] 기존 컴포넌트 → `_legacy/` 이동 - -#### 1.3.4 기능 테스트 -- [ ] Happy Path / Error Path / UI 검증 - -#### 1.3.5 정리 -- [ ] import 경로 정리 -- [ ] **예외 발생 시**: 문서화 - ---- - -### 1.4 Phase 1 완료 검증 - -- [ ] 3개 모듈 모두 정상 동작 확인 -- [ ] 발견된 예외 사항 정리 -- [ ] 템플릿 구조 검토 및 개선점 도출 -- [ ] **Phase 0 보완 필요 여부 판단**: - - [ ] 새로운 필드 타입 필요? - - [ ] 인터페이스 수정 필요? -- [ ] 공통 패턴 확인 및 리팩토링 -- [ ] Phase 2 진행 여부 결정 - ---- - -## Phase 2: 설정/게시판 모듈 (4개) - RichTextEditor 포함 - -> Phase 0.3 (richtext 지원) 완료 후 진행 - -### 2.1 settings/popup-management (팝업관리) ⚠️ RichTextEditor - -#### 2.1.1 현황 분석 -- [ ] 기존 컴포넌트 분석 (PopupForm + PopupDetail 분리 구조) -- [ ] 필드 목록 정리: - - [ ] target (select) - 대상 - - [ ] startDate, endDate (dateRange) - 기간 - - [ ] title (text) - 제목 - - [ ] content (richtext) - 내용 ⚠️ - - [ ] status (radio) - 상태 - - [ ] author (text, readonly) - 작성자 -- [ ] **예외 요소**: RichTextEditor → Phase 0.3 richtext 타입 사용 - -#### 2.1.2 config 파일 작성 -- [ ] `popupManagementConfig.ts` 작성 - -#### 2.1.3 페이지 마이그레이션 -- [ ] `new/page.tsx` 마이그레이션 -- [ ] `[id]/page.tsx` 마이그레이션 -- [ ] `[id]/edit/page.tsx` 마이그레이션 - -#### 2.1.4 기능 테스트 -- [ ] 특수: RichTextEditor 동작 확인 -- [ ] 특수: dateRange 동작 확인 - -#### 2.1.5 정리 - ---- - -### 2.2 board/board-management (게시판관리) -- [ ] 현황 분석 -- [ ] config 작성 -- [ ] 페이지 마이그레이션 (new, [id], [id]/edit) -- [ ] 기능 테스트 -- [ ] 정리 - -### 2.3 accounting/bad-debt-collection (악성채권추심) -- [ ] 현황 분석 -- [ ] config 작성 -- [ ] 페이지 마이그레이션 (new, [id], [id]/edit) -- [ ] 기능 테스트 -- [ ] 정리 - -### 2.4 hr/documents (증명서류) -- [ ] 현황 분석 -- [ ] config 작성 -- [ ] 페이지 마이그레이션 -- [ ] 기능 테스트 -- [ ] 정리 - -### 2.5 Phase 2 완료 검증 -- [ ] 4개 모듈 모두 정상 동작 확인 -- [ ] richtext, dateRange 필드 동작 확인 -- [ ] 예외 사항 정리 -- [ ] Phase 3 진행 여부 결정 - ---- - -## Phase 3: HR/판매 모듈 (6개) - 특수 상세 화면 포함 - -### 3.1 hr/employee-management (사원관리) -- [ ] 현황 분석 -- [ ] config 작성 -- [ ] 페이지 마이그레이션 (new, [id], [id]/edit) -- [ ] 기능 테스트 -- [ ] 정리 - -### 3.2 sales/client-management-sales-admin (거래처관리) -- [ ] 현황 분석 -- [ ] config 작성 -- [ ] 페이지 마이그레이션 (new, [id], [id]/edit) -- [ ] 기능 테스트 -- [ ] 정리 - -### 3.3 sales/quote-management (견적관리) ⚠️ 문서 모달 - -#### 특수 처리 -- [ ] `[id]/page.tsx`: `renderView` prop으로 QuoteDocument 모달 유지 -- [ ] 등록/수정은 일반 템플릿 적용 - -- [ ] 현황 분석 -- [ ] config 작성 (renderView 포함) -- [ ] 페이지 마이그레이션 -- [ ] 기능 테스트 (문서 모달 동작 확인) -- [ ] 정리 - -### 3.4 sales/order-management-sales (수주관리) ⚠️ 문서 모달 - -#### 특수 처리 -- [ ] `[id]/page.tsx`: `renderView` prop으로 OrderDocumentModal 유지 - -- [ ] 현황 분석 -- [ ] config 작성 (renderView 포함) -- [ ] 페이지 마이그레이션 -- [ ] 기능 테스트 -- [ ] 정리 - -### 3.5 sales/pricing-management (단가관리) -- [ ] 현황 분석 -- [ ] config 작성 -- [ ] 페이지 마이그레이션 (new, [id], [id]/edit) -- [ ] 기능 테스트 -- [ ] 정리 - -### 3.6 customer-center/qna (Q&A) -- [ ] 현황 분석 -- [ ] config 작성 -- [ ] 페이지 마이그레이션 (new, [id], [id]/edit) -- [ ] 기능 테스트 -- [ ] 정리 - -### 3.7 Phase 3 완료 검증 -- [ ] 6개 모듈 모두 정상 동작 확인 -- [ ] **renderView 동작 확인** (문서 모달) -- [ ] 예외 사항 정리 -- [ ] Phase 4 진행 여부 결정 - ---- - -## Phase 4: 생산/출고/품질 모듈 (4개) - 복잡한 상세 화면 - -### 4.1 production/work-orders (작업지시관리) ⚠️ 카드 레이아웃 - -#### 특수 처리 -- [ ] `[id]/page.tsx`: `renderView` prop으로 WorkOrderDetail (카드 레이아웃) 유지 - -- [ ] 현황 분석 -- [ ] config 작성 (renderView 포함) -- [ ] 페이지 마이그레이션 -- [ ] 기능 테스트 -- [ ] 정리 - -### 4.2 production/screen-production (스크린생산) ⚠️ 복합 레이아웃 - -#### 특수 처리 -- [ ] `[id]/page.tsx`: `renderView` prop으로 ItemDetailClient 유지 - -- [ ] 현황 분석 -- [ ] config 작성 (renderView 포함) -- [ ] 페이지 마이그레이션 -- [ ] 기능 테스트 -- [ ] 정리 - -### 4.3 outbound/shipments (출하관리) -- [ ] 현황 분석 -- [ ] config 작성 -- [ ] 페이지 마이그레이션 (new, [id], [id]/edit) -- [ ] 기능 테스트 -- [ ] 정리 - -### 4.4 quality/inspections (검사관리) -- [ ] 현황 분석 -- [ ] config 작성 -- [ ] 페이지 마이그레이션 (new, [id], [id]/edit) -- [ ] 기능 테스트 -- [ ] 정리 - -### 4.5 master-data/process-management (공정관리) ⚠️ RuleModal - -#### 특수 처리 -- [ ] **복잡도 재평가**: RuleModal (분류규칙 배열 관리) -- [ ] Option A: `renderForm`으로 기존 ProcessForm 유지 -- [ ] Option B: 배열 필드 타입 추가 후 마이그레이션 -- [ ] **결정 후 진행** - -- [ ] 현황 분석 -- [ ] 처리 방안 결정 -- [ ] config 또는 renderForm 작성 -- [ ] 페이지 마이그레이션 -- [ ] 기능 테스트 -- [ ] 정리 - -### 4.6 Phase 4 완료 검증 -- [ ] 모듈 정상 동작 확인 -- [ ] 복잡한 renderView 동작 확인 -- [ ] 예외 사항 정리 -- [ ] Phase 5 진행 여부 결정 - ---- - -## Phase 5: 건설 모듈 (6개) - -### 5.1 construction/project/bidding/partners (거래처-입찰) -- [ ] 현황 분석 / config 작성 / 마이그레이션 / 테스트 / 정리 - -### 5.2 construction/project/bidding/site-briefings (현장설명회) -- [ ] 현황 분석 / config 작성 / 마이그레이션 / 테스트 / 정리 - -### 5.3 construction/project/contract (계약관리) -- [ ] 현황 분석 / config 작성 / 마이그레이션 / 테스트 / 정리 - -### 5.4 construction/project/issue-management (이슈관리) -- [ ] 현황 분석 / config 작성 / 마이그레이션 / 테스트 / 정리 - -### 5.5 construction/order/base-info/pricing (단가-건설) -- [ ] 현황 분석 / config 작성 / 마이그레이션 / 테스트 / 정리 - -### 5.6 construction/order/base-info/labor (노임-건설) -- [ ] 현황 분석 / config 작성 / 마이그레이션 / 테스트 / 정리 - -### 5.7 Phase 5 완료 검증 -- [ ] 6개 모듈 모두 정상 동작 확인 -- [ ] 예외 사항 정리 -- [ ] Phase 6 진행 여부 결정 - ---- - -## Phase 6: 나머지 패턴 (B, C, D) - -### 6.1 패턴 B (등록+상세, mode로 수정) -> accounting/bills, accounting/sales, accounting/vendors - -- [ ] accounting/bills 마이그레이션 -- [ ] accounting/sales 마이그레이션 -- [ ] accounting/vendors 마이그레이션 -- [ ] 기능 테스트 - -### 6.2 패턴 C (상세+수정, 등록 없음) -> construction 모듈 대부분 - -- [ ] construction/billing/progress-billing-management -- [ ] construction/order/order-management -- [ ] construction/order/site-management -- [ ] construction/order/structure-review -- [ ] construction/project/bidding -- [ ] construction/project/bidding/estimates -- [ ] construction/project/construction-management -- [ ] construction/project/contract/handover-report -- [ ] sales/quote-management/test -- [ ] 기능 테스트 - -### 6.3 패턴 D (상세만, 조회 전용) -> 10개 모듈 - -- [ ] accounting/deposits -- [ ] accounting/purchase -- [ ] accounting/vendor-ledger -- [ ] accounting/withdrawals -- [ ] customer-center/notices -- [ ] customer-center/events -- [ ] material/receiving-management -- [ ] material/stock-status -- [ ] construction/project/management -- [ ] sales/order-management-sales/production-orders -- [ ] 기능 테스트 - -### 6.4 Phase 6 완료 검증 -- [ ] 모든 패턴 정상 동작 확인 -- [ ] 예외 사항 최종 정리 - ---- - -## 🚫 제외 대상 - -| 모듈 | 제외 사유 | -|------|----------| -| items (품목관리) 3개 페이지 | DynamicItemForm 사용 (동적 폼) | -| (추가 발견 시 기록) | - | - ---- - -## 최종 검증 - -- [ ] 전체 마이그레이션 완료 페이지 수 확인 -- [ ] 제외 대상 확인 및 문서화 -- [ ] 전체 기능 회귀 테스트 -- [ ] 성능 테스트 (로딩 속도) -- [ ] 반응형 레이아웃 전체 확인 -- [ ] `_legacy` 폴더 정리 (최종 삭제) -- [ ] 예외 기록 최종 검토 - ---- - -## 통계 - -| Phase | 모듈 수 | 예상 페이지 수 | 상태 | -|-------|--------|--------------|------| -| Phase 0 사전준비 | - | - | ⬜ 대기 | -| Phase 0 템플릿 | - | - | ⬜ 대기 | -| Phase 1 | 3 | 7 | ⬜ 대기 | -| Phase 2 | 4 | 12 | ⬜ 대기 | -| Phase 3 | 6 | 18 | ⬜ 대기 | -| Phase 4 | 5 | 15 | ⬜ 대기 | -| Phase 5 | 6 | 16 | ⬜ 대기 | -| Phase 6 | 22 | 38 | ⬜ 대기 | -| **합계** | **46** | **~106** | - | - -> 제외: 품목관리(items) 3개 페이지 (동적 폼) +| 날짜 | 모듈 | View | Edit | Create | 에러UI | URL 구조 | 결과 | +|------|------|------|------|--------|--------|----------|------| +| 2026-01-19 | 거래처(영업) | ✅ | ✅ | ✅ | ✅ | `?mode=view\|edit` | **PASS** | +| 2026-01-19 | 팝업관리 | ✅ | ✅ | ✅ | ✅ | `?mode=view\|edit` | **PASS** | +| 2026-01-19 | 공정관리 | ✅ | ✅ | ✅ | ✅ | `?mode=view\|edit` | **PASS** | +| 2026-01-19 | 게시판관리 | ✅ | ✅ | ✅ | ✅ | `?mode=view\|edit` | **PASS** | +| 2026-01-19 | 대손추심 | ✅ | ✅ | ✅ | ✅ | `?mode=view\|edit` | **PASS** | +| 2026-01-19 | Q&A | ✅ | ✅ | ✅ | ✅ | `?mode=view\|edit` | **PASS** | +| 2026-01-19 | 현장관리 | ✅ | ✅ | - | ✅ | `?mode=view\|edit` | **PASS** | +| 2026-01-19 | 실행내역 | ✅ | ✅ | - | ✅ | `?mode=view\|edit` | **PASS** | +| 2026-01-19 | 견적관리 | ✅ | ✅ | - | - | `?mode=view\|edit` | **PASS** | +| 2026-01-19 | 견적(테스트) | ✅ | ✅ | - | - | `?mode=view\|edit` | **PASS** | +| 2026-01-19 | 입찰관리 | ⏳ | ⏳ | - | - | `?mode=view\|edit` | **코드완료** | +| 2026-01-19 | 이슈관리 | ⏳ | ⏳ | - | - | `?mode=view\|edit` | **코드완료** | +| 2026-01-19 | 현장설명회 | ⏳ | ⏳ | - | - | `?mode=view\|edit` | **코드완료** | +| 2026-01-19 | 견적서(건설) | ⏳ | ⏳ | - | - | `?mode=view\|edit` | **코드완료** | + +**검증 항목**: +- `/new` → Create 모드 정상 동작 +- `/999` (존재하지 않는 ID) → ErrorCard 표시 (network 타입) +- `/1?mode=edit` → Edit 모드 전환 +- `/[id]/edit` → `/[id]?mode=edit` 리다이렉트 (하위 호환성) + +### 검수 실패 시 조치 + +1. **즉시 중단**: 해당 모듈 작업 중단 +2. **원인 분석**: Chrome DevTools 콘솔/네트워크 확인 +3. **수정**: 버그 수정 +4. **재검수**: 전체 항목 다시 검수 +5. **기록**: 이슈 내용 및 해결 방법 문서화 --- @@ -671,4 +1063,26 @@ interface FieldDefinition { | 날짜 | 버전 | 내용 | |------|------|------| | 2026-01-17 | v1 | 체크리스트 초기 작성 | -| 2026-01-17 | v2 | 심층 검토 반영 - Phase 1 모듈 변경, 예외 처리 프로세스 추가, 롤백 전략 추가, Phase 0 사전준비 추가, 필드 타입 인벤토리 추가 | +| 2026-01-17 | v2 | 심층 검토 반영 | +| 2026-01-19 | v3 | 내부 컴포넌트 공통화 통합 | +| 2026-01-19 | v4 | 스켈레톤 컴포넌트 추가 | +| 2026-01-19 | v5 | Chrome DevTools 동작 검증 완료 | +| 2026-01-19 | v6 | DetailField 미적용 이슈 발견 | +| 2026-01-19 | v7 | DetailField 미적용 이슈 해결 완료 | +| 2026-01-19 | v8 | **📊 47개 상세 페이지 전체 분석 완료**: 도메인별 상세 분류, Phase별 재정리, 통계 업데이트 | +| 2026-01-19 | v9 | **📋 리스트/상세 차이 설명 추가, 🧪 기능 검수 섹션 추가** | +| 2026-01-19 | v10 | **🔧 buttonPosition prop 추가**: 상단(top)/하단(bottom) 버튼 배치 지원 - Phase 2 회계 도메인 대비 +| 2026-01-19 | v11 | **🚀 노무관리 마이그레이션 완료**: LaborDetailClientV2 생성, Create 모드 검수 PASS (View/Edit 대기) +| 2026-01-19 | v12 | **🚀 단가관리(건설) 마이그레이션 완료**: PricingDetailClientV2 생성, fetchOptions/disabled 함수 패턴 활용, Create 모드 검수 PASS +| 2026-01-19 | v13 | **🚀 입금관리 마이그레이션 완료**: DepositDetailClientV2 생성, 거래처를 Phase 3으로 재분류, Create 모드 검수 PASS +| 2026-01-19 | v14 | **📊 Phase 2 분석 및 대규모 재분류**: 출금관리 완료 (4개 완료), 5개 페이지 Phase 3 이동 (매출/세금계산서/매입/재고현황/입고관리 - 테이블 포함), 통계 업데이트 +| 2026-01-19 | v15 | **✅ Phase 2 최종 완료**: 대기 6개 페이지 분석 완료 - 4개 Phase 3 이동 (별도 라우팅 구조: 거래처/팝업관리/공정관리/게시판관리), 2개 마이그레이션 불필요 (View only: 공지사항/이벤트) +| 2026-01-19 | v16 | **🚀 Phase 3 라우팅 구조 변경 4개 완료, 🎨 ErrorCard 공통 컴포넌트 추가**: 거래처(영업)/팝업관리/공정관리/게시판관리 V2 래퍼 패턴 적용, 에러 페이지 UI 통일용 ErrorCard 컴포넌트 신규 생성 +| 2026-01-19 | v17 | **🚀 Phase 3 대손추심 완료**: BadDebtDetailClientV2 생성 (기존 컴포넌트 래핑), 검수 PASS +| 2026-01-19 | v18 | **🚀 Phase 3 Q&A 완료**: InquiryDetailClientV2 생성 (기존 Detail/Form 컴포넌트 래핑), `/create` URL 패턴 지원, 검수 PASS +| 2026-01-19 | v19 | **🚀 Phase 3 건설/판매 도메인 3개 추가 완료**: 현장관리(SiteDetailClientV2), 실행내역(StructureReviewDetailClientV2), 견적관리(기존 page.tsx에 mode 체크) - 총 15개 완료, 18개 대기 +| 2026-01-19 | v20 | **🧪 견적 테스트 페이지 V2 패턴 적용**: `/sales/quote-management/test/[id]` 기존 page.tsx에 mode 체크 추가 (실제 API 연동 예정 페이지) - 총 16개 완료, 17개 대기, 제외 11개로 축소 +| 2026-01-19 | v21 | **🚀 Phase 3 건설 도메인 4개 추가 완료**: 입찰관리, 이슈관리, 현장설명회, 견적서(건설) - 기존 page.tsx에 mode 체크 추가 - 총 20개 완료, 13개 대기 +| 2026-01-19 | v22 | **🚨 ServerErrorPage 필수 적용 섹션 추가**: V2 마이그레이션 시 기본 div 에러 UI 대신 ServerErrorPage 컴포넌트 사용 필수 +| 2026-01-19 | v23 | **🚀 기성관리 V2 마이그레이션 완료**: `/construction/billing/progress-billing-management/[id]` - mode 체크 + ServerErrorPage 적용 - 총 21개 완료 +| 2026-01-19 | v24 | **📊 Phase 3 최종 분석 완료**: 품목관리(건설) 이미 V2 적용 확인, 3개 추가 제외 (수주관리-복잡워크플로우, 생산지시-조회전용복잡UI, 판매단가관리-Edit전용) diff --git a/claudedocs/dev/[PLAN] detail-page-pattern-classification.md b/claudedocs/dev/[PLAN] detail-page-pattern-classification.md new file mode 100644 index 00000000..5ce20c78 --- /dev/null +++ b/claudedocs/dev/[PLAN] detail-page-pattern-classification.md @@ -0,0 +1,155 @@ +# 상세/등록/수정 페이지 패턴 분류표 + +> Chrome DevTools MCP로 직접 확인한 결과 기반 (2026-01-19) + +## 패턴 분류 기준 + +### 1️⃣ 페이지 형태 - 하단 버튼 (표준 패턴) +- URL이 변경되며 별도 페이지로 이동 +- 버튼 위치: **하단** (좌: 목록/취소, 우: 삭제/수정/저장) +- **IntegratedDetailTemplate 적용 대상** + +### 2️⃣ 페이지 형태 - 상단 버튼 +- URL이 변경되며 별도 페이지로 이동 +- 버튼 위치: **상단** +- IntegratedDetailTemplate 확장 필요 (`buttonPosition="top"`) + +### 3️⃣ 모달 형태 +- URL 변경 없음, Dialog/Modal로 표시 +- **IntegratedDetailTemplate 적용 제외** + +### 4️⃣ 인라인 입력 형태 +- 리스트 페이지 내에서 직접 입력/수정 +- **IntegratedDetailTemplate 적용 제외** + +### 5️⃣ DynamicForm 형태 +- API 기반 동적 폼 생성 +- IntegratedDetailTemplate의 `renderForm` prop으로 분기 처리 + +--- + +## 📄 페이지 형태 - 하단 버튼 (통합 대상) + +| 도메인 | 페이지 | URL 패턴 | 상태 | +|--------|--------|----------|------| +| **설정** | 계좌관리 | `/settings/accounts/[id]`, `/new` | ✅ 이미 마이그레이션 완료 | +| **설정** | 카드관리 | `/hr/card-management/[id]`, `/new` | ✅ 이미 마이그레이션 완료 | +| **설정** | 팝업관리 | `/settings/popup-management/[id]`, `/new` | 🔄 대상 | +| **설정** | 게시판관리 | `/board/board-management/[id]`, `/new` | 🔄 대상 | +| **기준정보** | 공정관리 | `/master-data/process-management/[id]`, `/new` | 🔄 대상 | +| **판매** | 거래처관리 | `/sales/client-management-sales-admin/[id]`, `/new` | 🔄 대상 | +| **판매** | 견적관리 | `/sales/quote-management/[id]`, `/new` | 🔄 대상 | +| **판매** | 수주관리 | `/sales/order-management-sales/[id]`, `/new` | 🔄 대상 | +| **품질** | 검사관리 | `/quality/inspections/[id]`, `/new` | 🔄 대상 | +| **출고** | 출하관리 | `/outbound/shipments/[id]`, `/new` | 🔄 대상 | +| **고객센터** | 공지사항 | `/customer-center/notices/[id]` | 🔄 대상 | +| **고객센터** | 이벤트 | `/customer-center/events/[id]` | 🔄 대상 | + +--- + +## 📄 페이지 형태 - 상단 버튼 (확장 필요) + +| 도메인 | 페이지 | URL 패턴 | 버튼 구성 | 비고 | +|--------|--------|----------|-----------|------| +| **회계** | 거래처관리 | `/accounting/vendors/[id]`, `/new` | 목록/삭제/수정 | 다중 섹션 구조 | +| **회계** | 매출관리 | `/accounting/sales/[id]`, `/new` | - | 🔄 대상 | +| **회계** | 매입관리 | `/accounting/purchase/[id]` | - | 🔄 대상 | +| **회계** | 입금관리 | `/accounting/deposits/[id]` | - | 🔄 대상 | +| **회계** | 출금관리 | `/accounting/withdrawals/[id]` | - | 🔄 대상 | +| **회계** | 어음관리 | `/accounting/bills/[id]`, `/new` | - | 🔄 대상 | +| **회계** | 악성채권 | `/accounting/bad-debt-collection/[id]`, `/new` | - | 🔄 대상 | +| **전자결재** | 기안함 (임시저장) | `/approval/draft/new?id=:id&mode=edit` | 상세/삭제/상신/저장 | 복잡한 섹션 구조 | + +--- + +## 🔲 모달 형태 (통합 제외) + +| 도메인 | 페이지 | 모달 컴포넌트 | 비고 | +|--------|--------|--------------|------| +| **설정** | 직급관리 | `RankDialog.tsx` | 인라인 입력 + 수정 모달 | +| **설정** | 직책관리 | `TitleDialog.tsx` | 인라인 입력 + 수정 모달 | +| **인사** | 부서관리 | `DepartmentDialog.tsx` | 트리 구조 | +| **인사** | 근태관리 | `AttendanceInfoDialog.tsx` | 모달로 등록 | +| **인사** | 휴가관리 | `VacationRequestDialog.tsx` | 모달로 등록/조정 | +| **인사** | 급여관리 | `SalaryDetailDialog.tsx` | 모달로 상세 | +| **전자결재** | 기안함 (결재대기) | 품의서 상세 Dialog | 상세만 모달 | +| **건설** | 카테고리관리 | `CategoryDialog.tsx` | 모달로 등록/수정 | + +--- + +## 🔧 DynamicForm 형태 (renderForm 분기) + +| 도메인 | 페이지 | URL 패턴 | 비고 | +|--------|--------|----------|------| +| **품목** | 품목관리 | `/items/[id]` | `DynamicItemForm` 사용 | + +--- + +## ⚠️ 특수 케이스 (개별 처리 필요) + +| 도메인 | 페이지 | URL 패턴 | 특이사항 | +|--------|--------|----------|----------| +| **설정** | 권한관리 | `/settings/permissions/[id]`, `/new` | Matrix UI, 복잡한 구조 | +| **인사** | 사원관리 | `/hr/employee-management/[id]`, `/new` | 40+ 필드, 탭 구조 | +| **게시판** | 게시글 | `/board/[boardCode]/[postId]` | 동적 게시판 | +| **건설** | 다수 페이지 | `/construction/...` | 별도 분류 필요 | + +--- + +## 📊 통합 우선순위 + +### Phase 1: 단순 CRUD (우선 작업) +1. 팝업관리 +2. 게시판관리 +3. 공정관리 +4. 공지사항/이벤트 + +### Phase 2: 중간 복잡도 +1. 판매 > 거래처관리 +2. 판매 > 견적관리 +3. 품질 > 검사관리 +4. 출고 > 출하관리 + +### Phase 3: 회계 도메인 (상단 버튼 확장 후) +1. 회계 > 거래처관리 +2. 회계 > 매출/매입/입금/출금 +3. 회계 > 어음/악성채권 + +### 제외 (개별 유지) +- 권한관리 (Matrix UI) +- 사원관리 (40+ 필드) +- 부서관리 (트리 구조) +- 전자결재 (복잡한 워크플로우) +- DynamicForm 페이지 (renderForm 분기) +- 모달 형태 페이지들 + +--- + +## IntegratedDetailTemplate 확장 필요 Props + +```typescript +interface IntegratedDetailTemplateProps { + // 기존 props... + + // 버튼 위치 제어 + buttonPosition?: 'top' | 'bottom'; // default: 'bottom' + + // 뒤로가기 버튼 표시 여부 + showBackButton?: boolean; // default: true + + // 상단 버튼 커스텀 (문서 결재 등) + headerActions?: ReactNode; + + // 다중 섹션 지원 + sections?: Array<{ + title: string; + fields: FieldConfig[]; + }>; +} +``` + +--- + +## 작성일 +- 최초 작성: 2026-01-19 +- Chrome DevTools MCP 확인 완료 diff --git a/claudedocs/dev/[REF] chrome-devtools-mcp-emoji-issue.md b/claudedocs/dev/[REF] chrome-devtools-mcp-emoji-issue.md new file mode 100644 index 00000000..0342adfa --- /dev/null +++ b/claudedocs/dev/[REF] chrome-devtools-mcp-emoji-issue.md @@ -0,0 +1,118 @@ +# Chrome DevTools MCP - 이모지 JSON 직렬화 오류 + +> 작성일: 2025-01-17 + +## 문제 현상 + +Chrome DevTools MCP가 특정 페이지 접근 시 다운되는 현상 + +### 에러 메시지 +``` +API Error: 400 {"type":"error","error":{"type":"invalid_request_error", +"message":"The request body is not valid JSON: invalid high surrogate in string: +line 1 column XXXXX (char XXXXX)"},"request_id":"req_XXXXX"} +``` + +### 발생 조건 +- 페이지에 **이모지**가 많이 포함된 경우 +- `take_snapshot` 또는 다른 MCP 도구 호출 시 +- a11y tree를 JSON으로 직렬화하는 과정에서 발생 + +## 원인 + +### 유니코드 서로게이트 쌍 (Surrogate Pair) 문제 + +이모지는 UTF-16에서 **서로게이트 쌍**으로 인코딩됨: +- High surrogate: U+D800 ~ U+DBFF +- Low surrogate: U+DC00 ~ U+DFFF + +Chrome DevTools MCP가 페이지 스냅샷을 JSON으로 직렬화할 때, 이모지의 서로게이트 쌍이 깨지면서 "invalid high surrogate" 오류 발생. + +### 문제가 되는 케이스 +1. **DOM에 직접 렌더링된 이모지**: `🏠` +2. **데이터에 포함된 이모지**: API 응답, 파싱된 데이터 +3. **대량의 이모지**: 수십 개 이상의 이모지가 한 페이지에 존재 + +## 해결 방법 + +### 1. 이모지를 Lucide 아이콘으로 교체 (UI) + +**Before** +```tsx +const iconMap = { + '기본': '🏠', + '인사관리': '👥', +}; + +{category.icon} +``` + +**After** +```tsx +import { Home, Users, type LucideIcon } from 'lucide-react'; + +const iconComponents: Record = { + Home, + Users, +}; + +function CategoryIcon({ name }: { name: string }) { + const IconComponent = iconComponents[name] || FileText; + return ; +} + + +``` + +### 2. 데이터 파싱 시 이모지 제거/변환 (Server) + +```typescript +function convertEmojiToText(text: string): string { + // 특정 이모지를 의미있는 텍스트로 변환 + let result = text + .replace(/✅/g, '[완료]') + .replace(/⚠️?/g, '[주의]') + .replace(/🧪/g, '[테스트]') + .replace(/🆕/g, '[NEW]') + .replace(/•/g, '-'); + + // 모든 이모지 및 특수 유니코드 문자 제거 + result = result + .replace(/[\u{1F300}-\u{1F9FF}]/gu, '') // 이모지 범위 + .replace(/[\u{2600}-\u{26FF}]/gu, '') // 기타 기호 + .replace(/[\u{2700}-\u{27BF}]/gu, '') // 딩뱃 + .replace(/[\u{FE00}-\u{FE0F}]/gu, '') // Variation Selectors + .replace(/[\u{1F000}-\u{1F02F}]/gu, '') // 마작 타일 + .replace(/[\u{1F0A0}-\u{1F0FF}]/gu, '') // 플레잉 카드 + .replace(/[\u200D]/g, '') // Zero Width Joiner + .trim(); + + return result; +} +``` + +## 체크리스트 + +새 페이지 개발 시 Chrome DevTools MCP 호환성 확인: + +- [ ] 페이지에 이모지 직접 렌더링하지 않음 +- [ ] 아이콘은 Lucide 또는 SVG 사용 +- [ ] 외부 데이터(API, 파일) 파싱 시 이모지 제거 처리 +- [ ] status, label 등에 이모지 대신 텍스트 사용 + +## 관련 파일 + +이 문제로 수정된 파일들: + +| 파일 | 변경 내용 | +|------|----------| +| `dev/test-urls/actions.ts` | iconMap, convertEmojiToText 함수 추가 | +| `dev/test-urls/TestUrlsClient.tsx` | Lucide 아이콘 동적 렌더링 | +| `dev/construction-test-urls/actions.ts` | 동일 | +| `dev/construction-test-urls/ConstructionTestUrlsClient.tsx` | 동일 | + +## 참고 + +- 이 문제는 Chrome DevTools MCP의 JSON 직렬화 로직에서 발생 +- MCP 자체 버그일 가능성 있으나, 클라이언트에서 이모지 제거로 우회 가능 +- 다른 MCP 도구에서도 비슷한 문제 발생 가능성 있음 diff --git a/src/app/[locale]/(protected)/accounting/bad-debt-collection/[id]/edit/page.tsx b/src/app/[locale]/(protected)/accounting/bad-debt-collection/[id]/edit/page.tsx index 2d964ca9..2a27fd6d 100644 --- a/src/app/[locale]/(protected)/accounting/bad-debt-collection/[id]/edit/page.tsx +++ b/src/app/[locale]/(protected)/accounting/bad-debt-collection/[id]/edit/page.tsx @@ -1,58 +1,27 @@ 'use client'; -import { use, useEffect, useState } from 'react'; +import { use, useEffect } from 'react'; import { useRouter } from 'next/navigation'; -import { getBadDebtById } from '@/components/accounting/BadDebtCollection/actions'; -import { BadDebtDetail } from '@/components/accounting/BadDebtCollection/BadDebtDetail'; -import type { BadDebtRecord } from '@/components/accounting/BadDebtCollection/types'; interface EditBadDebtPageProps { params: Promise<{ id: string }>; } +/** + * 하위 호환성을 위한 리다이렉트 페이지 + * /bad-debt-collection/[id]/edit → /bad-debt-collection/[id]?mode=edit + */ export default function EditBadDebtPage({ params }: EditBadDebtPageProps) { const { id } = use(params); const router = useRouter(); - const [data, setData] = useState(null); - const [isLoading, setIsLoading] = useState(true); - const [error, setError] = useState(null); useEffect(() => { - getBadDebtById(id) - .then(result => { - if (result) { - setData(result); - } else { - setError('데이터를 찾을 수 없습니다.'); - } - }) - .catch(() => { - setError('데이터를 불러오는 중 오류가 발생했습니다.'); - }) - .finally(() => setIsLoading(false)); - }, [id]); + router.replace(`/ko/accounting/bad-debt-collection/${id}?mode=edit`); + }, [id, router]); - if (isLoading) { - return ( -
-
로딩 중...
-
- ); - } - - if (error || !data) { - return ( -
-
{error || '데이터를 찾을 수 없습니다.'}
- -
- ); - } - - return ; -} \ No newline at end of file + return ( +
+
리다이렉트 중...
+
+ ); +} diff --git a/src/app/[locale]/(protected)/accounting/bad-debt-collection/[id]/page.tsx b/src/app/[locale]/(protected)/accounting/bad-debt-collection/[id]/page.tsx index 282395bd..7a1b12c2 100644 --- a/src/app/[locale]/(protected)/accounting/bad-debt-collection/[id]/page.tsx +++ b/src/app/[locale]/(protected)/accounting/bad-debt-collection/[id]/page.tsx @@ -1,10 +1,7 @@ 'use client'; -import { use, useEffect, useState } from 'react'; -import { useRouter } from 'next/navigation'; -import { getBadDebtById } from '@/components/accounting/BadDebtCollection/actions'; -import { BadDebtDetail } from '@/components/accounting/BadDebtCollection/BadDebtDetail'; -import type { BadDebtRecord } from '@/components/accounting/BadDebtCollection/types'; +import { use } from 'react'; +import { BadDebtDetailClientV2 } from '@/components/accounting/BadDebtCollection'; interface BadDebtDetailPageProps { params: Promise<{ id: string }>; @@ -12,47 +9,5 @@ interface BadDebtDetailPageProps { export default function BadDebtDetailPage({ params }: BadDebtDetailPageProps) { const { id } = use(params); - const router = useRouter(); - const [data, setData] = useState(null); - const [isLoading, setIsLoading] = useState(true); - const [error, setError] = useState(null); - - useEffect(() => { - getBadDebtById(id) - .then(result => { - if (result) { - setData(result); - } else { - setError('데이터를 찾을 수 없습니다.'); - } - }) - .catch(() => { - setError('데이터를 불러오는 중 오류가 발생했습니다.'); - }) - .finally(() => setIsLoading(false)); - }, [id]); - - if (isLoading) { - return ( -
-
로딩 중...
-
- ); - } - - if (error || !data) { - return ( -
-
{error || '데이터를 찾을 수 없습니다.'}
- -
- ); - } - - return ; -} \ No newline at end of file + return ; +} diff --git a/src/app/[locale]/(protected)/accounting/bad-debt-collection/new/page.tsx b/src/app/[locale]/(protected)/accounting/bad-debt-collection/new/page.tsx index 342d5bb7..dde58bff 100644 --- a/src/app/[locale]/(protected)/accounting/bad-debt-collection/new/page.tsx +++ b/src/app/[locale]/(protected)/accounting/bad-debt-collection/new/page.tsx @@ -1,7 +1,7 @@ 'use client'; -import { BadDebtDetail } from '@/components/accounting/BadDebtCollection/BadDebtDetail'; +import { BadDebtDetailClientV2 } from '@/components/accounting/BadDebtCollection'; export default function NewBadDebtPage() { - return ; -} \ No newline at end of file + return ; +} diff --git a/src/app/[locale]/(protected)/accounting/deposits/[id]/edit/page.tsx b/src/app/[locale]/(protected)/accounting/deposits/[id]/edit/page.tsx new file mode 100644 index 00000000..854256df --- /dev/null +++ b/src/app/[locale]/(protected)/accounting/deposits/[id]/edit/page.tsx @@ -0,0 +1,14 @@ +'use client'; + +import { use } from 'react'; +import DepositDetailClientV2 from '@/components/accounting/DepositManagement/DepositDetailClientV2'; + +interface PageProps { + params: Promise<{ id: string }>; +} + +export default function DepositEditPage({ params }: PageProps) { + const { id } = use(params); + + return ; +} diff --git a/src/app/[locale]/(protected)/accounting/deposits/[id]/page.tsx b/src/app/[locale]/(protected)/accounting/deposits/[id]/page.tsx index 56fd4747..5b4754b6 100644 --- a/src/app/[locale]/(protected)/accounting/deposits/[id]/page.tsx +++ b/src/app/[locale]/(protected)/accounting/deposits/[id]/page.tsx @@ -1,13 +1,14 @@ 'use client'; -import { useParams, useSearchParams } from 'next/navigation'; -import { DepositDetail } from '@/components/accounting/DepositManagement/DepositDetail'; +import { use } from 'react'; +import DepositDetailClientV2 from '@/components/accounting/DepositManagement/DepositDetailClientV2'; -export default function DepositDetailPage() { - const params = useParams(); - const searchParams = useSearchParams(); - const depositId = params.id as string; - const mode = searchParams.get('mode') === 'edit' ? 'edit' : 'view'; +interface PageProps { + params: Promise<{ id: string }>; +} - return ; +export default function DepositDetailPage({ params }: PageProps) { + const { id } = use(params); + + return ; } \ No newline at end of file diff --git a/src/app/[locale]/(protected)/accounting/deposits/new/page.tsx b/src/app/[locale]/(protected)/accounting/deposits/new/page.tsx new file mode 100644 index 00000000..2df329bd --- /dev/null +++ b/src/app/[locale]/(protected)/accounting/deposits/new/page.tsx @@ -0,0 +1,7 @@ +'use client'; + +import DepositDetailClientV2 from '@/components/accounting/DepositManagement/DepositDetailClientV2'; + +export default function DepositNewPage() { + return ; +} diff --git a/src/app/[locale]/(protected)/accounting/withdrawals/[id]/edit/page.tsx b/src/app/[locale]/(protected)/accounting/withdrawals/[id]/edit/page.tsx new file mode 100644 index 00000000..0b37a3a3 --- /dev/null +++ b/src/app/[locale]/(protected)/accounting/withdrawals/[id]/edit/page.tsx @@ -0,0 +1,14 @@ +'use client'; + +import { use } from 'react'; +import WithdrawalDetailClientV2 from '@/components/accounting/WithdrawalManagement/WithdrawalDetailClientV2'; + +interface PageProps { + params: Promise<{ id: string }>; +} + +export default function WithdrawalEditPage({ params }: PageProps) { + const { id } = use(params); + + return ; +} diff --git a/src/app/[locale]/(protected)/accounting/withdrawals/[id]/page.tsx b/src/app/[locale]/(protected)/accounting/withdrawals/[id]/page.tsx index a27b057d..2a9ec1b2 100644 --- a/src/app/[locale]/(protected)/accounting/withdrawals/[id]/page.tsx +++ b/src/app/[locale]/(protected)/accounting/withdrawals/[id]/page.tsx @@ -1,13 +1,14 @@ 'use client'; -import { useParams, useSearchParams } from 'next/navigation'; -import { WithdrawalDetail } from '@/components/accounting/WithdrawalManagement/WithdrawalDetail'; +import { use } from 'react'; +import WithdrawalDetailClientV2 from '@/components/accounting/WithdrawalManagement/WithdrawalDetailClientV2'; -export default function WithdrawalDetailPage() { - const params = useParams(); - const searchParams = useSearchParams(); - const withdrawalId = params.id as string; - const mode = searchParams.get('mode') === 'edit' ? 'edit' : 'view'; +interface PageProps { + params: Promise<{ id: string }>; +} - return ; +export default function WithdrawalDetailPage({ params }: PageProps) { + const { id } = use(params); + + return ; } \ No newline at end of file diff --git a/src/app/[locale]/(protected)/accounting/withdrawals/new/page.tsx b/src/app/[locale]/(protected)/accounting/withdrawals/new/page.tsx new file mode 100644 index 00000000..72d3b9d0 --- /dev/null +++ b/src/app/[locale]/(protected)/accounting/withdrawals/new/page.tsx @@ -0,0 +1,7 @@ +'use client'; + +import WithdrawalDetailClientV2 from '@/components/accounting/WithdrawalManagement/WithdrawalDetailClientV2'; + +export default function WithdrawalNewPage() { + return ; +} diff --git a/src/app/[locale]/(protected)/board/board-management/[id]/edit/page.tsx b/src/app/[locale]/(protected)/board/board-management/[id]/edit/page.tsx index ad093909..4bae1040 100644 --- a/src/app/[locale]/(protected)/board/board-management/[id]/edit/page.tsx +++ b/src/app/[locale]/(protected)/board/board-management/[id]/edit/page.tsx @@ -1,116 +1,24 @@ 'use client'; -import { useRouter, useParams } from 'next/navigation'; -import { useState, useEffect, useCallback } from 'react'; -import { Loader2 } from 'lucide-react'; -import { ContentLoadingSpinner } from '@/components/ui/loading-spinner'; -import { BoardForm } from '@/components/board/BoardManagement/BoardForm'; -import { getBoardById, updateBoard } from '@/components/board/BoardManagement/actions'; -import { forceRefreshMenus } from '@/lib/utils/menuRefresh'; -import { Button } from '@/components/ui/button'; -import type { Board, BoardFormData } from '@/components/board/BoardManagement/types'; +/** + * 게시판관리 수정 페이지 (리다이렉트) + * + * 기존 URL 호환성을 위해 유지 + * /[id]/edit → /[id]?mode=edit 로 리다이렉트 + */ -export default function BoardEditPage() { +import { useEffect } from 'react'; +import { useParams, useRouter } from 'next/navigation'; +import { ContentLoadingSpinner } from '@/components/ui/loading-spinner'; + +export default function BoardEditRedirectPage() { const router = useRouter(); const params = useParams(); - const [board, setBoard] = useState(null); - const [isLoading, setIsLoading] = useState(true); - const [error, setError] = useState(null); - const [isSubmitting, setIsSubmitting] = useState(false); - - const fetchBoard = useCallback(async () => { - const id = params.id as string; - if (!id) return; - - setIsLoading(true); - setError(null); - - const result = await getBoardById(id); - - if (result.success && result.data) { - setBoard(result.data); - } else { - setError(result.error || '게시판 정보를 불러오는데 실패했습니다.'); - } - - setIsLoading(false); - }, [params.id]); + const id = params.id as string; useEffect(() => { - fetchBoard(); - }, [fetchBoard]); + router.replace(`/ko/board/board-management/${id}?mode=edit`); + }, [router, id]); - const handleSubmit = async (data: BoardFormData) => { - if (!board) return; - - setIsSubmitting(true); - setError(null); - - const result = await updateBoard(board.id, { - ...data, - boardCode: board.boardCode, - description: board.description, - }); - - if (result.success) { - // 게시판 수정 성공 시 메뉴 즉시 갱신 - await forceRefreshMenus(); - router.push(`/ko/board/board-management/${board.id}`); - } else { - setError(result.error || '수정에 실패했습니다.'); - } - - setIsSubmitting(false); - }; - - // 로딩 상태 - if (isLoading) { - return ; - } - - // 에러 상태 - if (error && !board) { - return ( -
-

{error}

- -
- ); - } - - if (!board) { - return ( -
-

게시판을 찾을 수 없습니다.

- -
- ); - } - - return ( - <> - {error && ( -
-

{error}

-
- )} - - {isSubmitting && ( -
-
- - 저장 중... -
-
- )} - - ); + return ; } diff --git a/src/app/[locale]/(protected)/board/board-management/[id]/page.tsx b/src/app/[locale]/(protected)/board/board-management/[id]/page.tsx index b93de1cc..83a5f9f7 100644 --- a/src/app/[locale]/(protected)/board/board-management/[id]/page.tsx +++ b/src/app/[locale]/(protected)/board/board-management/[id]/page.tsx @@ -1,134 +1,19 @@ 'use client'; -import { useRouter, useParams } from 'next/navigation'; -import { useState, useEffect, useCallback } from 'react'; -import { Loader2 } from 'lucide-react'; -import { ContentLoadingSpinner } from '@/components/ui/loading-spinner'; -import { BoardDetail } from '@/components/board/BoardManagement/BoardDetail'; -import { getBoardById, deleteBoard } from '@/components/board/BoardManagement/actions'; -import { Button } from '@/components/ui/button'; -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from '@/components/ui/alert-dialog'; -import type { Board } from '@/components/board/BoardManagement/types'; +/** + * 게시판관리 상세/수정 페이지 + * + * IntegratedDetailTemplate V2 마이그레이션 + * - /[id] → 상세 보기 (view 모드) + * - /[id]?mode=edit → 수정 (edit 모드) + */ + +import { useParams } from 'next/navigation'; +import { BoardDetailClientV2 } from '@/components/board/BoardManagement/BoardDetailClientV2'; export default function BoardDetailPage() { - const router = useRouter(); const params = useParams(); - const [board, setBoard] = useState(null); - const [isLoading, setIsLoading] = useState(true); - const [error, setError] = useState(null); - const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); - const [isDeleting, setIsDeleting] = useState(false); + const id = params.id as string; - const fetchBoard = useCallback(async () => { - const id = params.id as string; - if (!id) return; - - setIsLoading(true); - setError(null); - - const result = await getBoardById(id); - - if (result.success && result.data) { - setBoard(result.data); - } else { - setError(result.error || '게시판 정보를 불러오는데 실패했습니다.'); - } - - setIsLoading(false); - }, [params.id]); - - useEffect(() => { - fetchBoard(); - }, [fetchBoard]); - - const handleEdit = () => { - router.push(`/ko/board/board-management/${params.id}/edit`); - }; - - const handleDelete = () => { - setDeleteDialogOpen(true); - }; - - const confirmDelete = async () => { - if (!board) return; - - setIsDeleting(true); - const result = await deleteBoard(board.id); - - if (result.success) { - router.push('/ko/board/board-management'); - } else { - setError(result.error || '삭제에 실패했습니다.'); - setDeleteDialogOpen(false); - } - setIsDeleting(false); - }; - - // 로딩 상태 - if (isLoading) { - return ; - } - - // 에러 상태 - if (error || !board) { - return ( -
-

{error || '게시판을 찾을 수 없습니다.'}

- -
- ); - } - - return ( - <> - - - - - - 게시판 삭제 - - "{board.boardName}" 게시판을 삭제하시겠습니까? -
- - 삭제된 게시판 정보는 복구할 수 없습니다. - -
-
- - 취소 - - {isDeleting ? ( - <> - - 삭제 중... - - ) : ( - '삭제' - )} - - -
-
- - ); + return ; } diff --git a/src/app/[locale]/(protected)/board/board-management/new/page.tsx b/src/app/[locale]/(protected)/board/board-management/new/page.tsx index a5e30375..0fc6920e 100644 --- a/src/app/[locale]/(protected)/board/board-management/new/page.tsx +++ b/src/app/[locale]/(protected)/board/board-management/new/page.tsx @@ -1,64 +1,13 @@ 'use client'; -import { useRouter } from 'next/navigation'; -import { useState } from 'react'; -import { Loader2 } from 'lucide-react'; -import { BoardForm } from '@/components/board/BoardManagement/BoardForm'; -import { createBoard } from '@/components/board/BoardManagement/actions'; -import { forceRefreshMenus } from '@/lib/utils/menuRefresh'; -import type { BoardFormData } from '@/components/board/BoardManagement/types'; +/** + * 게시판관리 등록 페이지 + * + * IntegratedDetailTemplate V2 마이그레이션 + */ -// 게시판 코드 생성 (임시: 타임스탬프 기반) -const generateBoardCode = (): string => { - const timestamp = Date.now().toString(36); - const random = Math.random().toString(36).substring(2, 6); - return `board_${timestamp}_${random}`; -}; +import { BoardDetailClientV2 } from '@/components/board/BoardManagement/BoardDetailClientV2'; -export default function BoardNewPage() { - const router = useRouter(); - const [error, setError] = useState(null); - const [isSubmitting, setIsSubmitting] = useState(false); - - const handleSubmit = async (data: BoardFormData) => { - setIsSubmitting(true); - setError(null); - - const result = await createBoard({ - ...data, - boardCode: generateBoardCode(), - }); - - if (result.success && result.data) { - // 게시판 생성 성공 시 메뉴 즉시 갱신 - await forceRefreshMenus(); - router.push('/ko/board/board-management'); - } else { - setError(result.error || '게시판 생성에 실패했습니다.'); - } - - setIsSubmitting(false); - }; - - return ( - <> - {error && ( -
-

{error}

-
- )} - - {isSubmitting && ( -
-
- - 등록 중... -
-
- )} - - ); +export default function BoardCreatePage() { + return ; } diff --git a/src/app/[locale]/(protected)/construction/billing/progress-billing-management/[id]/edit/page.tsx b/src/app/[locale]/(protected)/construction/billing/progress-billing-management/[id]/edit/page.tsx index 41cb4bcf..097d64ce 100644 --- a/src/app/[locale]/(protected)/construction/billing/progress-billing-management/[id]/edit/page.tsx +++ b/src/app/[locale]/(protected)/construction/billing/progress-billing-management/[id]/edit/page.tsx @@ -1,57 +1,27 @@ 'use client'; -import { use, useEffect, useState } from 'react'; +import { use, useEffect } from 'react'; import { useRouter } from 'next/navigation'; -import { ProgressBillingDetailForm } from '@/components/business/construction/progress-billing'; -import { getProgressBillingDetail } from '@/components/business/construction/progress-billing/actions'; interface ProgressBillingEditPageProps { params: Promise<{ id: string }>; } +/** + * 하위 호환성을 위한 리다이렉트 페이지 + * /progress-billing-management/[id]/edit → /progress-billing-management/[id]?mode=edit + */ export default function ProgressBillingEditPage({ params }: ProgressBillingEditPageProps) { const { id } = use(params); const router = useRouter(); - const [data, setData] = useState>['data']>(undefined); - const [isLoading, setIsLoading] = useState(true); - const [error, setError] = useState(null); useEffect(() => { - getProgressBillingDetail(id) - .then(result => { - if (result.success && result.data) { - setData(result.data); - } else { - setError('기성청구 정보를 찾을 수 없습니다.'); - } - }) - .catch(() => { - setError('기성청구 정보를 불러오는 중 오류가 발생했습니다.'); - }) - .finally(() => setIsLoading(false)); - }, [id]); + router.replace(`/ko/construction/billing/progress-billing-management/${id}?mode=edit`); + }, [id, router]); - if (isLoading) { - return ( -
-
로딩 중...
-
- ); - } - - if (error || !data) { - return ( -
-
{error || '기성청구 정보를 찾을 수 없습니다.'}
- -
- ); - } - - return ; + return ( +
+
리다이렉트 중...
+
+ ); } diff --git a/src/app/[locale]/(protected)/construction/billing/progress-billing-management/[id]/page.tsx b/src/app/[locale]/(protected)/construction/billing/progress-billing-management/[id]/page.tsx index 596afd43..3be49edc 100644 --- a/src/app/[locale]/(protected)/construction/billing/progress-billing-management/[id]/page.tsx +++ b/src/app/[locale]/(protected)/construction/billing/progress-billing-management/[id]/page.tsx @@ -1,9 +1,10 @@ 'use client'; -import { use, useEffect, useState } from 'react'; -import { useRouter } from 'next/navigation'; +import { use, useEffect, useState, useCallback } from 'react'; +import { useSearchParams } from 'next/navigation'; import { ProgressBillingDetailForm } from '@/components/business/construction/progress-billing'; import { getProgressBillingDetail } from '@/components/business/construction/progress-billing/actions'; +import { ServerErrorPage } from '@/components/common/ServerErrorPage'; interface ProgressBillingDetailPageProps { params: Promise<{ id: string }>; @@ -11,12 +12,19 @@ interface ProgressBillingDetailPageProps { export default function ProgressBillingDetailPage({ params }: ProgressBillingDetailPageProps) { const { id } = use(params); - const router = useRouter(); + const searchParams = useSearchParams(); + + // V2 패턴: mode 체크 + const mode = searchParams.get('mode') || 'view'; + const isEditMode = mode === 'edit'; + const [data, setData] = useState>['data']>(undefined); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); - useEffect(() => { + const fetchData = useCallback(() => { + setIsLoading(true); + setError(null); getProgressBillingDetail(id) .then(result => { if (result.success && result.data) { @@ -31,6 +39,10 @@ export default function ProgressBillingDetailPage({ params }: ProgressBillingDet .finally(() => setIsLoading(false)); }, [id]); + useEffect(() => { + fetchData(); + }, [fetchData]); + if (isLoading) { return (
@@ -41,17 +53,21 @@ export default function ProgressBillingDetailPage({ params }: ProgressBillingDet if (error || !data) { return ( -
-
{error || '기성청구 정보를 찾을 수 없습니다.'}
- -
+ ); } - return ; + return ( + + ); } diff --git a/src/app/[locale]/(protected)/construction/order/base-info/labor/[id]/page.tsx b/src/app/[locale]/(protected)/construction/order/base-info/labor/[id]/page.tsx index 45e54d8e..8bb8feb7 100644 --- a/src/app/[locale]/(protected)/construction/order/base-info/labor/[id]/page.tsx +++ b/src/app/[locale]/(protected)/construction/order/base-info/labor/[id]/page.tsx @@ -2,7 +2,8 @@ import { use } from 'react'; import { useSearchParams } from 'next/navigation'; -import { LaborDetailClient } from '@/components/business/construction/labor-management'; +import { LaborDetailClientV2 } from '@/components/business/construction/labor-management'; +import type { DetailMode } from '@/components/templates/IntegratedDetailTemplate'; interface LaborDetailPageProps { params: Promise<{ id: string }>; @@ -12,7 +13,7 @@ export default function LaborDetailPage({ params }: LaborDetailPageProps) { const { id } = use(params); const searchParams = useSearchParams(); const mode = searchParams.get('mode'); - const isEditMode = mode === 'edit'; + const initialMode: DetailMode = mode === 'edit' ? 'edit' : 'view'; - return ; + return ; } \ No newline at end of file diff --git a/src/app/[locale]/(protected)/construction/order/base-info/labor/new/page.tsx b/src/app/[locale]/(protected)/construction/order/base-info/labor/new/page.tsx index dcfb97c7..8a4064ff 100644 --- a/src/app/[locale]/(protected)/construction/order/base-info/labor/new/page.tsx +++ b/src/app/[locale]/(protected)/construction/order/base-info/labor/new/page.tsx @@ -1,5 +1,7 @@ -import { LaborDetailClient } from '@/components/business/construction/labor-management'; +'use client'; + +import { LaborDetailClientV2 } from '@/components/business/construction/labor-management'; export default function LaborNewPage() { - return ; + return ; } \ No newline at end of file diff --git a/src/app/[locale]/(protected)/construction/order/base-info/pricing/[id]/edit/page.tsx b/src/app/[locale]/(protected)/construction/order/base-info/pricing/[id]/edit/page.tsx index 5298d713..9cceb11b 100644 --- a/src/app/[locale]/(protected)/construction/order/base-info/pricing/[id]/edit/page.tsx +++ b/src/app/[locale]/(protected)/construction/order/base-info/pricing/[id]/edit/page.tsx @@ -1,7 +1,7 @@ 'use client'; import { use } from 'react'; -import PricingDetailClient from '@/components/business/construction/pricing-management/PricingDetailClient'; +import { PricingDetailClientV2 } from '@/components/business/construction/pricing-management'; interface PageProps { params: Promise<{ id: string }>; @@ -10,5 +10,5 @@ interface PageProps { export default function PricingEditPage({ params }: PageProps) { const { id } = use(params); - return ; + return ; } \ No newline at end of file diff --git a/src/app/[locale]/(protected)/construction/order/base-info/pricing/[id]/page.tsx b/src/app/[locale]/(protected)/construction/order/base-info/pricing/[id]/page.tsx index a5097a30..ee2af9f0 100644 --- a/src/app/[locale]/(protected)/construction/order/base-info/pricing/[id]/page.tsx +++ b/src/app/[locale]/(protected)/construction/order/base-info/pricing/[id]/page.tsx @@ -1,7 +1,7 @@ 'use client'; import { use } from 'react'; -import PricingDetailClient from '@/components/business/construction/pricing-management/PricingDetailClient'; +import { PricingDetailClientV2 } from '@/components/business/construction/pricing-management'; interface PageProps { params: Promise<{ id: string }>; @@ -10,5 +10,5 @@ interface PageProps { export default function PricingDetailPage({ params }: PageProps) { const { id } = use(params); - return ; + return ; } \ No newline at end of file diff --git a/src/app/[locale]/(protected)/construction/order/base-info/pricing/new/page.tsx b/src/app/[locale]/(protected)/construction/order/base-info/pricing/new/page.tsx index da6cc1b8..9b008da7 100644 --- a/src/app/[locale]/(protected)/construction/order/base-info/pricing/new/page.tsx +++ b/src/app/[locale]/(protected)/construction/order/base-info/pricing/new/page.tsx @@ -1,5 +1,7 @@ -import PricingDetailClient from '@/components/business/construction/pricing-management/PricingDetailClient'; +'use client'; + +import { PricingDetailClientV2 } from '@/components/business/construction/pricing-management'; export default function PricingNewPage() { - return ; + return ; } \ No newline at end of file diff --git a/src/app/[locale]/(protected)/construction/order/site-management/[id]/edit/page.tsx b/src/app/[locale]/(protected)/construction/order/site-management/[id]/edit/page.tsx index 27e86534..f0c66aee 100644 --- a/src/app/[locale]/(protected)/construction/order/site-management/[id]/edit/page.tsx +++ b/src/app/[locale]/(protected)/construction/order/site-management/[id]/edit/page.tsx @@ -1,43 +1,27 @@ 'use client'; -import { use, useEffect, useState } from 'react'; -import SiteDetailForm from '@/components/business/construction/site-management/SiteDetailForm'; +import { use, useEffect } from 'react'; +import { useRouter } from 'next/navigation'; -// 목업 데이터 -const MOCK_SITE = { - id: '1', - siteCode: '123-12-12345', - partnerId: '1', - partnerName: '거래처명', - siteName: '현장명', - address: '', - status: 'active' as const, - createdAt: '2025-09-01T00:00:00Z', - updatedAt: '2025-09-01T00:00:00Z', -}; - -interface PageProps { +interface EditSitePageProps { params: Promise<{ id: string }>; } -export default function SiteEditPage({ params }: PageProps) { +/** + * 하위 호환성을 위한 리다이렉트 페이지 + * /site-management/[id]/edit → /site-management/[id]?mode=edit + */ +export default function EditSitePage({ params }: EditSitePageProps) { const { id } = use(params); - const [site, setSite] = useState(null); - const [isLoading, setIsLoading] = useState(true); + const router = useRouter(); useEffect(() => { - // TODO: API에서 현장 정보 조회 - setSite({ ...MOCK_SITE, id }); - setIsLoading(false); - }, [id]); + router.replace(`/ko/construction/order/site-management/${id}?mode=edit`); + }, [id, router]); - if (isLoading || !site) { - return ( -
-
로딩 중...
-
- ); - } - - return ; -} \ No newline at end of file + return ( +
+
리다이렉트 중...
+
+ ); +} diff --git a/src/app/[locale]/(protected)/construction/order/site-management/[id]/page.tsx b/src/app/[locale]/(protected)/construction/order/site-management/[id]/page.tsx index 0ba0ee0d..75cc937f 100644 --- a/src/app/[locale]/(protected)/construction/order/site-management/[id]/page.tsx +++ b/src/app/[locale]/(protected)/construction/order/site-management/[id]/page.tsx @@ -1,43 +1,20 @@ 'use client'; -import { use, useEffect, useState } from 'react'; -import SiteDetailForm from '@/components/business/construction/site-management/SiteDetailForm'; +/** + * 현장관리 상세 페이지 (V2) + * + * /site-management/[id] → 조회 모드 + * /site-management/[id]?mode=edit → 수정 모드 + */ -// 목업 데이터 -const MOCK_SITE = { - id: '1', - siteCode: '123-12-12345', - partnerId: '1', - partnerName: '거래처명', - siteName: '현장명', - address: '', - status: 'active' as const, - createdAt: '2025-09-01T00:00:00Z', - updatedAt: '2025-09-01T00:00:00Z', -}; +import { use } from 'react'; +import { SiteDetailClientV2 } from '@/components/business/construction/site-management/SiteDetailClientV2'; -interface PageProps { +interface SiteDetailPageProps { params: Promise<{ id: string }>; } -export default function SiteDetailPage({ params }: PageProps) { +export default function SiteDetailPage({ params }: SiteDetailPageProps) { const { id } = use(params); - const [site, setSite] = useState(null); - const [isLoading, setIsLoading] = useState(true); - - useEffect(() => { - // TODO: API에서 현장 정보 조회 - setSite({ ...MOCK_SITE, id }); - setIsLoading(false); - }, [id]); - - if (isLoading || !site) { - return ( -
-
로딩 중...
-
- ); - } - - return ; -} \ No newline at end of file + return ; +} diff --git a/src/app/[locale]/(protected)/construction/order/structure-review/[id]/edit/page.tsx b/src/app/[locale]/(protected)/construction/order/structure-review/[id]/edit/page.tsx index c83f9720..f87991a3 100644 --- a/src/app/[locale]/(protected)/construction/order/structure-review/[id]/edit/page.tsx +++ b/src/app/[locale]/(protected)/construction/order/structure-review/[id]/edit/page.tsx @@ -1,48 +1,27 @@ 'use client'; -import { use, useEffect, useState } from 'react'; -import StructureReviewDetailForm from '@/components/business/construction/structure-review/StructureReviewDetailForm'; +import { use, useEffect } from 'react'; +import { useRouter } from 'next/navigation'; -// 목업 데이터 -const MOCK_REVIEW = { - id: '1', - reviewNumber: '123123', - partnerId: '1', - partnerName: '거래처명A', - siteId: '1', - siteName: '현장A', - requestDate: '2025-12-12', - reviewCompany: '회사명', - reviewerName: '홍길동', - reviewDate: '2025-12-15', - completionDate: null, - status: 'pending' as const, - createdAt: '2025-12-01T00:00:00Z', - updatedAt: '2025-12-01T00:00:00Z', -}; - -interface PageProps { +interface EditStructureReviewPageProps { params: Promise<{ id: string }>; } -export default function StructureReviewEditPage({ params }: PageProps) { +/** + * 하위 호환성을 위한 리다이렉트 페이지 + * /structure-review/[id]/edit → /structure-review/[id]?mode=edit + */ +export default function EditStructureReviewPage({ params }: EditStructureReviewPageProps) { const { id } = use(params); - const [review, setReview] = useState(null); - const [isLoading, setIsLoading] = useState(true); + const router = useRouter(); useEffect(() => { - // TODO: API에서 구조검토 정보 조회 - setReview({ ...MOCK_REVIEW, id }); - setIsLoading(false); - }, [id]); + router.replace(`/ko/construction/order/structure-review/${id}?mode=edit`); + }, [id, router]); - if (isLoading || !review) { - return ( -
-
로딩 중...
-
- ); - } - - return ; -} \ No newline at end of file + return ( +
+
리다이렉트 중...
+
+ ); +} diff --git a/src/app/[locale]/(protected)/construction/order/structure-review/[id]/page.tsx b/src/app/[locale]/(protected)/construction/order/structure-review/[id]/page.tsx index 31d8886e..7a6dd470 100644 --- a/src/app/[locale]/(protected)/construction/order/structure-review/[id]/page.tsx +++ b/src/app/[locale]/(protected)/construction/order/structure-review/[id]/page.tsx @@ -1,48 +1,20 @@ 'use client'; -import { use, useEffect, useState } from 'react'; -import StructureReviewDetailForm from '@/components/business/construction/structure-review/StructureReviewDetailForm'; +/** + * 구조검토 상세 페이지 (V2) + * + * /structure-review/[id] → 조회 모드 + * /structure-review/[id]?mode=edit → 수정 모드 + */ -// 목업 데이터 -const MOCK_REVIEW = { - id: '1', - reviewNumber: '123123', - partnerId: '1', - partnerName: '거래처명A', - siteId: '1', - siteName: '현장A', - requestDate: '2025-12-12', - reviewCompany: '회사명', - reviewerName: '홍길동', - reviewDate: '2025-12-15', - completionDate: null, - status: 'pending' as const, - createdAt: '2025-12-01T00:00:00Z', - updatedAt: '2025-12-01T00:00:00Z', -}; +import { use } from 'react'; +import { StructureReviewDetailClientV2 } from '@/components/business/construction/structure-review/StructureReviewDetailClientV2'; -interface PageProps { +interface StructureReviewDetailPageProps { params: Promise<{ id: string }>; } -export default function StructureReviewDetailPage({ params }: PageProps) { +export default function StructureReviewDetailPage({ params }: StructureReviewDetailPageProps) { const { id } = use(params); - const [review, setReview] = useState(null); - const [isLoading, setIsLoading] = useState(true); - - useEffect(() => { - // TODO: API에서 구조검토 정보 조회 - setReview({ ...MOCK_REVIEW, id }); - setIsLoading(false); - }, [id]); - - if (isLoading || !review) { - return ( -
-
로딩 중...
-
- ); - } - - return ; + return ; } diff --git a/src/app/[locale]/(protected)/construction/project/bidding/[id]/edit/page.tsx b/src/app/[locale]/(protected)/construction/project/bidding/[id]/edit/page.tsx index 782b6382..77d51552 100644 --- a/src/app/[locale]/(protected)/construction/project/bidding/[id]/edit/page.tsx +++ b/src/app/[locale]/(protected)/construction/project/bidding/[id]/edit/page.tsx @@ -1,38 +1,27 @@ 'use client'; -import { use, useEffect, useState } from 'react'; -import { BiddingDetailForm, getBiddingDetail } from '@/components/business/construction/bidding'; +import { use, useEffect } from 'react'; +import { useRouter } from 'next/navigation'; interface BiddingEditPageProps { params: Promise<{ id: string }>; } +/** + * 하위 호환성을 위한 리다이렉트 페이지 + * /bidding/[id]/edit → /bidding/[id]?mode=edit + */ export default function BiddingEditPage({ params }: BiddingEditPageProps) { const { id } = use(params); - const [data, setData] = useState>['data']>(null); - const [isLoading, setIsLoading] = useState(true); + const router = useRouter(); useEffect(() => { - getBiddingDetail(id) - .then(result => { - setData(result.data); - }) - .finally(() => setIsLoading(false)); - }, [id]); - - if (isLoading) { - return ( -
-
로딩 중...
-
- ); - } + router.replace(`/ko/construction/project/bidding/${id}?mode=edit`); + }, [id, router]); return ( - +
+
리다이렉트 중...
+
); -} \ No newline at end of file +} diff --git a/src/app/[locale]/(protected)/construction/project/bidding/[id]/page.tsx b/src/app/[locale]/(protected)/construction/project/bidding/[id]/page.tsx index 51cec9a3..a02ae61c 100644 --- a/src/app/[locale]/(protected)/construction/project/bidding/[id]/page.tsx +++ b/src/app/[locale]/(protected)/construction/project/bidding/[id]/page.tsx @@ -1,7 +1,9 @@ 'use client'; -import { use, useEffect, useState } from 'react'; +import { use, useEffect, useState, useCallback } from 'react'; +import { useSearchParams } from 'next/navigation'; import { BiddingDetailForm, getBiddingDetail } from '@/components/business/construction/bidding'; +import { ServerErrorPage } from '@/components/common/ServerErrorPage'; interface BiddingDetailPageProps { params: Promise<{ id: string }>; @@ -9,17 +11,37 @@ interface BiddingDetailPageProps { export default function BiddingDetailPage({ params }: BiddingDetailPageProps) { const { id } = use(params); + const searchParams = useSearchParams(); + + // V2 패턴: mode 체크 + const mode = searchParams.get('mode') || 'view'; + const isEditMode = mode === 'edit'; + const [data, setData] = useState>['data']>(null); const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); - useEffect(() => { + const fetchData = useCallback(() => { + setIsLoading(true); + setError(null); getBiddingDetail(id) .then(result => { - setData(result.data); + if (result.success && result.data) { + setData(result.data); + } else { + setError('입찰 정보를 찾을 수 없습니다.'); + } + }) + .catch(() => { + setError('입찰 정보를 불러오는 중 오류가 발생했습니다.'); }) .finally(() => setIsLoading(false)); }, [id]); + useEffect(() => { + fetchData(); + }, [fetchData]); + if (isLoading) { return (
@@ -28,9 +50,21 @@ export default function BiddingDetailPage({ params }: BiddingDetailPageProps) { ); } + if (error) { + return ( + + ); + } + return ( diff --git a/src/app/[locale]/(protected)/construction/project/bidding/estimates/[id]/edit/page.tsx b/src/app/[locale]/(protected)/construction/project/bidding/estimates/[id]/edit/page.tsx index 815b3c85..64c0d318 100644 --- a/src/app/[locale]/(protected)/construction/project/bidding/estimates/[id]/edit/page.tsx +++ b/src/app/[locale]/(protected)/construction/project/bidding/estimates/[id]/edit/page.tsx @@ -1,61 +1,27 @@ 'use client'; -import { use, useEffect, useState } from 'react'; -import { EstimateDetailForm } from '@/components/business/construction/estimates'; -import type { EstimateDetail } from '@/components/business/construction/estimates'; -import { getEstimateDetail } from '@/components/business/construction/estimates/actions'; +import { use, useEffect } from 'react'; +import { useRouter } from 'next/navigation'; interface EstimateEditPageProps { params: Promise<{ id: string }>; } +/** + * 하위 호환성을 위한 리다이렉트 페이지 + * /estimates/[id]/edit → /estimates/[id]?mode=edit + */ export default function EstimateEditPage({ params }: EstimateEditPageProps) { const { id } = use(params); - const [data, setData] = useState(null); - const [isLoading, setIsLoading] = useState(true); - const [error, setError] = useState(null); + const router = useRouter(); useEffect(() => { - async function fetchData() { - try { - setIsLoading(true); - setError(null); - const result = await getEstimateDetail(id); - if (result.success && result.data) { - setData(result.data); - } else { - setError(result.error || '견적 정보를 불러오는데 실패했습니다.'); - } - } catch (err) { - setError(err instanceof Error ? err.message : '오류가 발생했습니다.'); - } finally { - setIsLoading(false); - } - } - fetchData(); - }, [id]); - - if (isLoading) { - return ( -
-
로딩 중...
-
- ); - } - - if (error) { - return ( -
-
{error}
-
- ); - } + router.replace(`/ko/construction/project/bidding/estimates/${id}?mode=edit`); + }, [id, router]); return ( - +
+
리다이렉트 중...
+
); -} \ No newline at end of file +} diff --git a/src/app/[locale]/(protected)/construction/project/bidding/estimates/[id]/page.tsx b/src/app/[locale]/(protected)/construction/project/bidding/estimates/[id]/page.tsx index 4778e319..a2593926 100644 --- a/src/app/[locale]/(protected)/construction/project/bidding/estimates/[id]/page.tsx +++ b/src/app/[locale]/(protected)/construction/project/bidding/estimates/[id]/page.tsx @@ -1,9 +1,11 @@ 'use client'; -import { use, useEffect, useState } from 'react'; +import { use, useEffect, useState, useCallback } from 'react'; +import { useSearchParams } from 'next/navigation'; import { EstimateDetailForm } from '@/components/business/construction/estimates'; import type { EstimateDetail } from '@/components/business/construction/estimates'; import { getEstimateDetail } from '@/components/business/construction/estimates/actions'; +import { ServerErrorPage } from '@/components/common/ServerErrorPage'; interface EstimateDetailPageProps { params: Promise<{ id: string }>; @@ -11,30 +13,37 @@ interface EstimateDetailPageProps { export default function EstimateDetailPage({ params }: EstimateDetailPageProps) { const { id } = use(params); + const searchParams = useSearchParams(); + + // V2 패턴: mode 체크 + const mode = searchParams.get('mode') || 'view'; + const isEditMode = mode === 'edit'; + const [data, setData] = useState(null); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); - useEffect(() => { - async function fetchData() { - try { - setIsLoading(true); - setError(null); - const result = await getEstimateDetail(id); - if (result.success && result.data) { - setData(result.data); - } else { - setError(result.error || '견적 정보를 불러오는데 실패했습니다.'); - } - } catch (err) { - setError(err instanceof Error ? err.message : '오류가 발생했습니다.'); - } finally { - setIsLoading(false); + const fetchData = useCallback(async () => { + try { + setIsLoading(true); + setError(null); + const result = await getEstimateDetail(id); + if (result.success && result.data) { + setData(result.data); + } else { + setError(result.error || '견적 정보를 불러오는데 실패했습니다.'); } + } catch (err) { + setError(err instanceof Error ? err.message : '오류가 발생했습니다.'); + } finally { + setIsLoading(false); } - fetchData(); }, [id]); + useEffect(() => { + fetchData(); + }, [fetchData]); + if (isLoading) { return (
@@ -45,15 +54,19 @@ export default function EstimateDetailPage({ params }: EstimateDetailPageProps) if (error) { return ( -
-
{error}
-
+ ); } return ( diff --git a/src/app/[locale]/(protected)/construction/project/bidding/partners/[id]/edit/page.tsx b/src/app/[locale]/(protected)/construction/project/bidding/partners/[id]/edit/page.tsx index 8f0bc930..8a2e7576 100644 --- a/src/app/[locale]/(protected)/construction/project/bidding/partners/[id]/edit/page.tsx +++ b/src/app/[locale]/(protected)/construction/project/bidding/partners/[id]/edit/page.tsx @@ -1,60 +1,27 @@ 'use client'; -import { use, useEffect, useState } from 'react'; +import { use, useEffect } from 'react'; import { useRouter } from 'next/navigation'; -import PartnerForm from '@/components/business/construction/partners/PartnerForm'; -import { getPartner } from '@/components/business/construction/partners/actions'; interface PartnerEditPageProps { params: Promise<{ id: string }>; } +/** + * 하위 호환성을 위한 리다이렉트 페이지 + * /partners/[id]/edit → /partners/[id]?mode=edit + */ export default function PartnerEditPage({ params }: PartnerEditPageProps) { const { id } = use(params); const router = useRouter(); - const [data, setData] = useState>['data']>(undefined); - const [isLoading, setIsLoading] = useState(true); - const [error, setError] = useState(null); useEffect(() => { - getPartner(id) - .then(result => { - if (result.success && result.data) { - setData(result.data); - } else { - setError('협력업체 정보를 찾을 수 없습니다.'); - } - }) - .catch(() => { - setError('협력업체 정보를 불러오는 중 오류가 발생했습니다.'); - }) - .finally(() => setIsLoading(false)); - }, [id]); - - if (isLoading) { - return ( -
-
로딩 중...
-
- ); - } - - if (error) { - return ( -
-
{error}
- -
- ); - } + router.replace(`/ko/construction/project/bidding/partners/${id}?mode=edit`); + }, [id, router]); return ( - +
+
리다이렉트 중...
+
); -} \ No newline at end of file +} diff --git a/src/app/[locale]/(protected)/construction/project/bidding/partners/[id]/page.tsx b/src/app/[locale]/(protected)/construction/project/bidding/partners/[id]/page.tsx index 17f41dd4..e58a6845 100644 --- a/src/app/[locale]/(protected)/construction/project/bidding/partners/[id]/page.tsx +++ b/src/app/[locale]/(protected)/construction/project/bidding/partners/[id]/page.tsx @@ -1,9 +1,10 @@ 'use client'; -import { use, useEffect, useState } from 'react'; -import { useRouter } from 'next/navigation'; +import { use, useEffect, useState, useCallback } from 'react'; +import { useSearchParams } from 'next/navigation'; import PartnerForm from '@/components/business/construction/partners/PartnerForm'; import { getPartner } from '@/components/business/construction/partners/actions'; +import { ServerErrorPage } from '@/components/common/ServerErrorPage'; interface PartnerDetailPageProps { params: Promise<{ id: string }>; @@ -11,12 +12,19 @@ interface PartnerDetailPageProps { export default function PartnerDetailPage({ params }: PartnerDetailPageProps) { const { id } = use(params); - const router = useRouter(); + const searchParams = useSearchParams(); + + // V2 패턴: mode 체크 + const mode = searchParams.get('mode') || 'view'; + const isEditMode = mode === 'edit'; + const [data, setData] = useState>['data']>(undefined); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); - useEffect(() => { + const fetchData = useCallback(() => { + setIsLoading(true); + setError(null); getPartner(id) .then(result => { if (result.success && result.data) { @@ -31,6 +39,10 @@ export default function PartnerDetailPage({ params }: PartnerDetailPageProps) { .finally(() => setIsLoading(false)); }, [id]); + useEffect(() => { + fetchData(); + }, [fetchData]); + if (isLoading) { return (
@@ -41,18 +53,19 @@ export default function PartnerDetailPage({ params }: PartnerDetailPageProps) { if (error) { return ( -
-
{error}
- -
+ ); } return ( diff --git a/src/app/[locale]/(protected)/construction/project/bidding/site-briefings/[id]/edit/page.tsx b/src/app/[locale]/(protected)/construction/project/bidding/site-briefings/[id]/edit/page.tsx index 19cb1f9b..a38c7687 100644 --- a/src/app/[locale]/(protected)/construction/project/bidding/site-briefings/[id]/edit/page.tsx +++ b/src/app/[locale]/(protected)/construction/project/bidding/site-briefings/[id]/edit/page.tsx @@ -1,59 +1,27 @@ 'use client'; -import { use, useEffect, useState } from 'react'; +import { use, useEffect } from 'react'; import { useRouter } from 'next/navigation'; -import { SiteBriefingForm, getSiteBriefing } from '@/components/business/construction/site-briefings'; interface SiteBriefingEditPageProps { params: Promise<{ id: string }>; } +/** + * 하위 호환성을 위한 리다이렉트 페이지 + * /site-briefings/[id]/edit → /site-briefings/[id]?mode=edit + */ export default function SiteBriefingEditPage({ params }: SiteBriefingEditPageProps) { const { id } = use(params); const router = useRouter(); - const [data, setData] = useState>['data']>(undefined); - const [isLoading, setIsLoading] = useState(true); - const [error, setError] = useState(null); useEffect(() => { - getSiteBriefing(id) - .then(result => { - if (result.success && result.data) { - setData(result.data); - } else { - setError('현장 설명회 정보를 찾을 수 없습니다.'); - } - }) - .catch(() => { - setError('현장 설명회 정보를 불러오는 중 오류가 발생했습니다.'); - }) - .finally(() => setIsLoading(false)); - }, [id]); - - if (isLoading) { - return ( -
-
로딩 중...
-
- ); - } - - if (error) { - return ( -
-
{error}
- -
- ); - } + router.replace(`/ko/construction/project/bidding/site-briefings/${id}?mode=edit`); + }, [id, router]); return ( - +
+
리다이렉트 중...
+
); -} \ No newline at end of file +} diff --git a/src/app/[locale]/(protected)/construction/project/bidding/site-briefings/[id]/page.tsx b/src/app/[locale]/(protected)/construction/project/bidding/site-briefings/[id]/page.tsx index 2c8524c0..ac188ba4 100644 --- a/src/app/[locale]/(protected)/construction/project/bidding/site-briefings/[id]/page.tsx +++ b/src/app/[locale]/(protected)/construction/project/bidding/site-briefings/[id]/page.tsx @@ -1,8 +1,9 @@ 'use client'; -import { use, useEffect, useState } from 'react'; -import { useRouter } from 'next/navigation'; +import { use, useEffect, useState, useCallback } from 'react'; +import { useSearchParams } from 'next/navigation'; import { SiteBriefingForm, getSiteBriefing } from '@/components/business/construction/site-briefings'; +import { ServerErrorPage } from '@/components/common/ServerErrorPage'; interface SiteBriefingDetailPageProps { params: Promise<{ id: string }>; @@ -10,12 +11,19 @@ interface SiteBriefingDetailPageProps { export default function SiteBriefingDetailPage({ params }: SiteBriefingDetailPageProps) { const { id } = use(params); - const router = useRouter(); + const searchParams = useSearchParams(); + + // V2 패턴: mode 체크 + const mode = searchParams.get('mode') || 'view'; + const isEditMode = mode === 'edit'; + const [data, setData] = useState>['data']>(undefined); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); - useEffect(() => { + const fetchData = useCallback(() => { + setIsLoading(true); + setError(null); getSiteBriefing(id) .then(result => { if (result.success && result.data) { @@ -30,6 +38,10 @@ export default function SiteBriefingDetailPage({ params }: SiteBriefingDetailPag .finally(() => setIsLoading(false)); }, [id]); + useEffect(() => { + fetchData(); + }, [fetchData]); + if (isLoading) { return (
@@ -40,18 +52,19 @@ export default function SiteBriefingDetailPage({ params }: SiteBriefingDetailPag if (error) { return ( -
-
{error}
- -
+ ); } return ( diff --git a/src/app/[locale]/(protected)/construction/project/construction-management/[id]/edit/page.tsx b/src/app/[locale]/(protected)/construction/project/construction-management/[id]/edit/page.tsx index 8b8285e9..1bd9ff98 100644 --- a/src/app/[locale]/(protected)/construction/project/construction-management/[id]/edit/page.tsx +++ b/src/app/[locale]/(protected)/construction/project/construction-management/[id]/edit/page.tsx @@ -1,7 +1,7 @@ 'use client'; -import { use } from 'react'; -import ConstructionDetailClient from '@/components/business/construction/management/ConstructionDetailClient'; +import { use, useEffect } from 'react'; +import { useRouter } from 'next/navigation'; interface PageProps { params: Promise<{ @@ -9,8 +9,21 @@ interface PageProps { }>; } +/** + * 하위 호환성을 위한 리다이렉트 페이지 + * /construction-management/[id]/edit → /construction-management/[id]?mode=edit + */ export default function ConstructionManagementEditPage({ params }: PageProps) { const { id } = use(params); + const router = useRouter(); - return ; -} \ No newline at end of file + useEffect(() => { + router.replace(`/ko/construction/project/construction-management/${id}?mode=edit`); + }, [id, router]); + + return ( +
+
리다이렉트 중...
+
+ ); +} diff --git a/src/app/[locale]/(protected)/construction/project/construction-management/[id]/page.tsx b/src/app/[locale]/(protected)/construction/project/construction-management/[id]/page.tsx index 86244c16..d12b3a1c 100644 --- a/src/app/[locale]/(protected)/construction/project/construction-management/[id]/page.tsx +++ b/src/app/[locale]/(protected)/construction/project/construction-management/[id]/page.tsx @@ -1,6 +1,7 @@ 'use client'; import { use } from 'react'; +import { useSearchParams } from 'next/navigation'; import ConstructionDetailClient from '@/components/business/construction/management/ConstructionDetailClient'; interface PageProps { @@ -11,6 +12,11 @@ interface PageProps { export default function ConstructionManagementDetailPage({ params }: PageProps) { const { id } = use(params); + const searchParams = useSearchParams(); - return ; + // V2 패턴: mode 체크 + const mode = searchParams.get('mode') || 'view'; + const isEditMode = mode === 'edit'; + + return ; } diff --git a/src/app/[locale]/(protected)/construction/project/issue-management/[id]/edit/page.tsx b/src/app/[locale]/(protected)/construction/project/issue-management/[id]/edit/page.tsx index 3b06bdca..d7a87cb6 100644 --- a/src/app/[locale]/(protected)/construction/project/issue-management/[id]/edit/page.tsx +++ b/src/app/[locale]/(protected)/construction/project/issue-management/[id]/edit/page.tsx @@ -1,53 +1,24 @@ 'use client'; -import { useEffect, useState } from 'react'; -import { useParams } from 'next/navigation'; -import IssueDetailForm from '@/components/business/construction/issue-management/IssueDetailForm'; -import { getIssue } from '@/components/business/construction/issue-management/actions'; -import type { Issue } from '@/components/business/construction/issue-management/types'; +import { useEffect } from 'react'; +import { useParams, useRouter } from 'next/navigation'; +/** + * 하위 호환성을 위한 리다이렉트 페이지 + * /issue-management/[id]/edit → /issue-management/[id]?mode=edit + */ export default function IssueEditPage() { const params = useParams(); + const router = useRouter(); const id = params.id as string; - const [issue, setIssue] = useState(undefined); - const [isLoading, setIsLoading] = useState(true); - const [error, setError] = useState(null); - useEffect(() => { - const loadData = async () => { - try { - const result = await getIssue(id); - if (result.success && result.data) { - setIssue(result.data); - } else { - setError(result.error || '이슈를 찾을 수 없습니다.'); - } - } catch { - setError('이슈 조회에 실패했습니다.'); - } finally { - setIsLoading(false); - } - }; + router.replace(`/ko/construction/project/issue-management/${id}?mode=edit`); + }, [id, router]); - loadData(); - }, [id]); - - if (isLoading) { - return ( -
-
로딩 중...
-
- ); - } - - if (error) { - return ( -
-
{error}
-
- ); - } - - return ; -} \ No newline at end of file + return ( +
+
리다이렉트 중...
+
+ ); +} diff --git a/src/app/[locale]/(protected)/construction/project/issue-management/[id]/page.tsx b/src/app/[locale]/(protected)/construction/project/issue-management/[id]/page.tsx index 79138f40..7dbd2769 100644 --- a/src/app/[locale]/(protected)/construction/project/issue-management/[id]/page.tsx +++ b/src/app/[locale]/(protected)/construction/project/issue-management/[id]/page.tsx @@ -1,38 +1,46 @@ 'use client'; -import { useEffect, useState } from 'react'; -import { useParams } from 'next/navigation'; +import { useEffect, useState, useCallback } from 'react'; +import { useParams, useSearchParams } from 'next/navigation'; import IssueDetailForm from '@/components/business/construction/issue-management/IssueDetailForm'; import { getIssue } from '@/components/business/construction/issue-management/actions'; import type { Issue } from '@/components/business/construction/issue-management/types'; +import { ServerErrorPage } from '@/components/common/ServerErrorPage'; export default function IssueDetailPage() { const params = useParams(); + const searchParams = useSearchParams(); const id = params.id as string; + // V2 패턴: mode 체크 + const mode = searchParams.get('mode') || 'view'; + const isEditMode = mode === 'edit'; + const [issue, setIssue] = useState(undefined); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); - useEffect(() => { - const loadData = async () => { - try { - const result = await getIssue(id); - if (result.success && result.data) { - setIssue(result.data); - } else { - setError(result.error || '이슈를 찾을 수 없습니다.'); - } - } catch { - setError('이슈 조회에 실패했습니다.'); - } finally { - setIsLoading(false); + const fetchData = useCallback(async () => { + try { + setIsLoading(true); + setError(null); + const result = await getIssue(id); + if (result.success && result.data) { + setIssue(result.data); + } else { + setError(result.error || '이슈를 찾을 수 없습니다.'); } - }; - - loadData(); + } catch { + setError('이슈 조회에 실패했습니다.'); + } finally { + setIsLoading(false); + } }, [id]); + useEffect(() => { + fetchData(); + }, [fetchData]); + if (isLoading) { return (
@@ -43,11 +51,15 @@ export default function IssueDetailPage() { if (error) { return ( -
-
{error}
-
+ ); } - return ; + return ; } \ No newline at end of file diff --git a/src/app/[locale]/(protected)/customer-center/qna/[id]/edit/page.tsx b/src/app/[locale]/(protected)/customer-center/qna/[id]/edit/page.tsx index bf2770da..7d5f35cb 100644 --- a/src/app/[locale]/(protected)/customer-center/qna/[id]/edit/page.tsx +++ b/src/app/[locale]/(protected)/customer-center/qna/[id]/edit/page.tsx @@ -1,57 +1,27 @@ 'use client'; +import { use, useEffect } from 'react'; +import { useRouter } from 'next/navigation'; + +interface EditInquiryPageProps { + params: Promise<{ id: string }>; +} + /** - * 1:1 문의 수정 페이지 + * 하위 호환성을 위한 리다이렉트 페이지 + * /qna/[id]/edit → /qna/[id]?mode=edit */ - -import { useParams } from 'next/navigation'; -import { useState, useEffect } from 'react'; -import { InquiryForm } from '@/components/customer-center/InquiryManagement'; -import { transformPostToInquiry, type Inquiry } from '@/components/customer-center/InquiryManagement/types'; -import { getPost } from '@/components/customer-center/shared/actions'; - -export default function InquiryEditPage() { - const params = useParams(); - const inquiryId = params.id as string; - - const [inquiry, setInquiry] = useState(null); - const [isLoading, setIsLoading] = useState(true); - const [error, setError] = useState(null); +export default function EditInquiryPage({ params }: EditInquiryPageProps) { + const { id } = use(params); + const router = useRouter(); useEffect(() => { - async function fetchInquiry() { - setIsLoading(true); - setError(null); + router.replace(`/ko/customer-center/qna/${id}?mode=edit`); + }, [id, router]); - const result = await getPost('qna', inquiryId); - - if (result.success && result.data) { - setInquiry(transformPostToInquiry(result.data)); - } else { - setError(result.error || '문의를 찾을 수 없습니다.'); - } - - setIsLoading(false); - } - - fetchInquiry(); - }, [inquiryId]); - - if (isLoading) { - return ( -
-

로딩 중...

-
- ); - } - - if (error || !inquiry) { - return ( -
-

{error || '문의를 찾을 수 없습니다.'}

-
- ); - } - - return ; + return ( +
+
리다이렉트 중...
+
+ ); } diff --git a/src/app/[locale]/(protected)/customer-center/qna/[id]/page.tsx b/src/app/[locale]/(protected)/customer-center/qna/[id]/page.tsx index 82ed3455..be3cb8e0 100644 --- a/src/app/[locale]/(protected)/customer-center/qna/[id]/page.tsx +++ b/src/app/[locale]/(protected)/customer-center/qna/[id]/page.tsx @@ -1,135 +1,20 @@ 'use client'; /** - * 1:1 문의 상세 페이지 + * 1:1 문의 상세 페이지 (V2) + * + * /qna/[id] → 조회 모드 + * /qna/[id]?mode=edit → 수정 모드 */ -import { useParams } from 'next/navigation'; -import { useState, useEffect, useCallback } from 'react'; -import { InquiryDetail } from '@/components/customer-center/InquiryManagement'; -import { transformPostToInquiry, type Inquiry, type Comment } from '@/components/customer-center/InquiryManagement/types'; -import { getPost, getComments, createComment, updateComment, deleteComment, deletePost } from '@/components/customer-center/shared/actions'; -import { transformApiToComment } from '@/components/customer-center/shared/types'; +import { use } from 'react'; +import { InquiryDetailClientV2 } from '@/components/customer-center/InquiryManagement'; -export default function InquiryDetailPage() { - const params = useParams(); - const inquiryId = params.id as string; - - const [inquiry, setInquiry] = useState(null); - const [comments, setComments] = useState([]); - const [isLoading, setIsLoading] = useState(true); - const [error, setError] = useState(null); - const [currentUserId, setCurrentUserId] = useState(''); - - // 현재 사용자 ID 가져오기 (localStorage에서) - useEffect(() => { - const userStr = localStorage.getItem('user'); - if (userStr) { - try { - const user = JSON.parse(userStr); - // user.id는 실제 DB user ID (숫자) - setCurrentUserId(String(user.id || '')); - } catch { - setCurrentUserId(''); - } - } - }, []); - - // 데이터 로드 - useEffect(() => { - async function fetchData() { - setIsLoading(true); - setError(null); - - // 게시글과 댓글 동시 로드 - const [postResult, commentsResult] = await Promise.all([ - getPost('qna', inquiryId), - getComments('qna', inquiryId), - ]); - - if (postResult.success && postResult.data) { - setInquiry(transformPostToInquiry(postResult.data)); - } else { - setError(postResult.error || '문의를 찾을 수 없습니다.'); - } - - if (commentsResult.success && commentsResult.data) { - setComments(commentsResult.data.map(transformApiToComment)); - } - - setIsLoading(false); - } - - fetchData(); - }, [inquiryId]); - - // 댓글 추가 - const handleAddComment = useCallback(async (content: string) => { - const result = await createComment('qna', inquiryId, content); - if (result.success && result.data) { - setComments((prev) => [...prev, transformApiToComment(result.data!)]); - } else { - console.error('댓글 등록 실패:', result.error); - } - }, [inquiryId]); - - // 댓글 수정 - const handleUpdateComment = useCallback(async (commentId: string, content: string) => { - const result = await updateComment('qna', inquiryId, commentId, content); - if (result.success && result.data) { - setComments((prev) => - prev.map((c) => (c.id === commentId ? transformApiToComment(result.data!) : c)) - ); - } else { - console.error('댓글 수정 실패:', result.error); - } - }, [inquiryId]); - - // 댓글 삭제 - const handleDeleteComment = useCallback(async (commentId: string) => { - const result = await deleteComment('qna', inquiryId, commentId); - if (result.success) { - setComments((prev) => prev.filter((c) => c.id !== commentId)); - } else { - console.error('댓글 삭제 실패:', result.error); - } - }, [inquiryId]); - - // 문의 삭제 - const handleDeleteInquiry = useCallback(async () => { - const result = await deletePost('qna', inquiryId); - return result.success; - }, [inquiryId]); - - if (isLoading) { - return ( -
-

로딩 중...

-
- ); - } - - if (error || !inquiry) { - return ( -
-

{error || '문의를 찾을 수 없습니다.'}

-
- ); - } - - // 답변은 추후 API 추가 시 구현 - const reply = undefined; - - return ( - - ); +interface InquiryDetailPageProps { + params: Promise<{ id: string }>; +} + +export default function InquiryDetailPage({ params }: InquiryDetailPageProps) { + const { id } = use(params); + return ; } diff --git a/src/app/[locale]/(protected)/customer-center/qna/create/page.tsx b/src/app/[locale]/(protected)/customer-center/qna/create/page.tsx index 60a1e738..2faf536a 100644 --- a/src/app/[locale]/(protected)/customer-center/qna/create/page.tsx +++ b/src/app/[locale]/(protected)/customer-center/qna/create/page.tsx @@ -1,14 +1,11 @@ +'use client'; + /** - * 1:1 문의 등록 페이지 + * 1:1 문의 등록 페이지 (V2) */ -import { InquiryForm } from '@/components/customer-center/InquiryManagement'; +import { InquiryDetailClientV2 } from '@/components/customer-center/InquiryManagement'; export default function InquiryCreatePage() { - return ; + return ; } - -export const metadata = { - title: '1:1 문의 등록', - description: '1:1 문의를 등록합니다.', -}; \ No newline at end of file diff --git a/src/app/[locale]/(protected)/master-data/process-management/[id]/edit/page.tsx b/src/app/[locale]/(protected)/master-data/process-management/[id]/edit/page.tsx index 24457f13..81dc5bb1 100644 --- a/src/app/[locale]/(protected)/master-data/process-management/[id]/edit/page.tsx +++ b/src/app/[locale]/(protected)/master-data/process-management/[id]/edit/page.tsx @@ -1,62 +1,27 @@ 'use client'; /** - * 공정 수정 페이지 (Client Component) + * 공정관리 수정 페이지 (리다이렉트) + * + * 기존 URL 호환성을 위해 유지 + * /[id]/edit → /[id]?mode=edit 로 리다이렉트 */ -import { use, useEffect, useState } from 'react'; +import { use, useEffect } from 'react'; import { useRouter } from 'next/navigation'; -import { ProcessForm } from '@/components/process-management'; -import { getProcessById } from '@/components/process-management/actions'; -import type { Process } from '@/components/process-management/types'; +import { ContentLoadingSpinner } from '@/components/ui/loading-spinner'; -export default function EditProcessPage({ +export default function ProcessEditRedirectPage({ params, }: { params: Promise<{ id: string }>; }) { const { id } = use(params); const router = useRouter(); - const [data, setData] = useState(null); - const [isLoading, setIsLoading] = useState(true); - const [error, setError] = useState(null); useEffect(() => { - getProcessById(id) - .then(result => { - if (result.success && result.data) { - setData(result.data); - } else { - setError('공정 정보를 찾을 수 없습니다.'); - } - }) - .catch(() => { - setError('공정 정보를 불러오는 중 오류가 발생했습니다.'); - }) - .finally(() => setIsLoading(false)); - }, [id]); + router.replace(`/ko/master-data/process-management/${id}?mode=edit`); + }, [router, id]); - if (isLoading) { - return ( -
-
공정 정보를 불러오는 중...
-
- ); - } - - if (error || !data) { - return ( -
-
{error || '공정을 찾을 수 없습니다.'}
- -
- ); - } - - return ; -} \ No newline at end of file + return ; +} diff --git a/src/app/[locale]/(protected)/master-data/process-management/[id]/page.tsx b/src/app/[locale]/(protected)/master-data/process-management/[id]/page.tsx index 8f5f70f9..9a28455f 100644 --- a/src/app/[locale]/(protected)/master-data/process-management/[id]/page.tsx +++ b/src/app/[locale]/(protected)/master-data/process-management/[id]/page.tsx @@ -1,14 +1,15 @@ 'use client'; /** - * 공정 상세 페이지 (Client Component) + * 공정관리 상세/수정 페이지 + * + * IntegratedDetailTemplate V2 마이그레이션 + * - /[id] → 상세 보기 (view 모드) + * - /[id]?mode=edit → 수정 (edit 모드) */ -import { use, useEffect, useState } from 'react'; -import { useRouter } from 'next/navigation'; -import { ProcessDetail } from '@/components/process-management'; -import { getProcessById } from '@/components/process-management/actions'; -import type { Process } from '@/components/process-management/types'; +import { use } from 'react'; +import { ProcessDetailClientV2 } from '@/components/process-management'; export default function ProcessDetailPage({ params, @@ -16,47 +17,6 @@ export default function ProcessDetailPage({ params: Promise<{ id: string }>; }) { const { id } = use(params); - const router = useRouter(); - const [data, setData] = useState(null); - const [isLoading, setIsLoading] = useState(true); - const [error, setError] = useState(null); - useEffect(() => { - getProcessById(id) - .then(result => { - if (result.success && result.data) { - setData(result.data); - } else { - setError('공정 정보를 찾을 수 없습니다.'); - } - }) - .catch(() => { - setError('공정 정보를 불러오는 중 오류가 발생했습니다.'); - }) - .finally(() => setIsLoading(false)); - }, [id]); - - if (isLoading) { - return ( -
-
공정 정보를 불러오는 중...
-
- ); - } - - if (error || !data) { - return ( -
-
{error || '공정을 찾을 수 없습니다.'}
- -
- ); - } - - return ; -} \ No newline at end of file + return ; +} diff --git a/src/app/[locale]/(protected)/master-data/process-management/new/page.tsx b/src/app/[locale]/(protected)/master-data/process-management/new/page.tsx index f6984856..3e4468cd 100644 --- a/src/app/[locale]/(protected)/master-data/process-management/new/page.tsx +++ b/src/app/[locale]/(protected)/master-data/process-management/new/page.tsx @@ -1,11 +1,13 @@ -/** - * 공정 등록 페이지 - */ - 'use client'; -import { ProcessForm } from '@/components/process-management'; +/** + * 공정관리 등록 페이지 + * + * IntegratedDetailTemplate V2 마이그레이션 + */ -export default function CreateProcessPage() { - return ; -} \ No newline at end of file +import { ProcessDetailClientV2 } from '@/components/process-management'; + +export default function ProcessCreatePage() { + return ; +} diff --git a/src/app/[locale]/(protected)/sales/client-management-sales-admin/[id]/edit/page.tsx b/src/app/[locale]/(protected)/sales/client-management-sales-admin/[id]/edit/page.tsx index 2496489e..aeb7fb44 100644 --- a/src/app/[locale]/(protected)/sales/client-management-sales-admin/[id]/edit/page.tsx +++ b/src/app/[locale]/(protected)/sales/client-management-sales-admin/[id]/edit/page.tsx @@ -1,78 +1,24 @@ +'use client'; + /** - * 거래처 수정 페이지 + * 거래처(영업) 수정 페이지 (리다이렉트) + * + * 기존 URL 호환성을 위해 유지 + * /[id]/edit → /[id]?mode=edit 로 리다이렉트 */ -"use client"; +import { useEffect } from 'react'; +import { useParams, useRouter } from 'next/navigation'; +import { ContentLoadingSpinner } from '@/components/ui/loading-spinner'; -import { useEffect, useState } from "react"; -import { useRouter, useParams } from "next/navigation"; -import { ClientRegistration } from "@/components/clients/ClientRegistration"; -import { - useClientList, - ClientFormData, - clientToFormData, -} from "@/hooks/useClientList"; -import { toast } from "sonner"; -import { ContentLoadingSpinner } from "@/components/ui/loading-spinner"; - -export default function ClientEditPage() { +export default function ClientEditRedirectPage() { const router = useRouter(); const params = useParams(); const id = params.id as string; - const { fetchClient, updateClient, isLoading: hookLoading } = useClientList(); - const [editingClient, setEditingClient] = useState( - null - ); - const [isLoading, setIsLoading] = useState(true); - - // 데이터 로드 useEffect(() => { - const loadClient = async () => { - if (!id) return; + router.replace(`/ko/sales/client-management-sales-admin/${id}?mode=edit`); + }, [router, id]); - setIsLoading(true); - try { - const data = await fetchClient(id); - if (data) { - setEditingClient(clientToFormData(data)); - } else { - toast.error("거래처를 찾을 수 없습니다."); - router.push("/sales/client-management-sales-admin"); - } - } catch (error) { - toast.error("데이터 로드 중 오류가 발생했습니다."); - router.push("/sales/client-management-sales-admin"); - } finally { - setIsLoading(false); - } - }; - - loadClient(); - }, [id, fetchClient, router]); - - const handleBack = () => { - router.push(`/sales/client-management-sales-admin/${id}`); - }; - - const handleSave = async (formData: ClientFormData) => { - await updateClient(id, formData); - }; - - if (isLoading) { - return ; - } - - if (!editingClient) { - return null; - } - - return ( - - ); -} \ No newline at end of file + return ; +} diff --git a/src/app/[locale]/(protected)/sales/client-management-sales-admin/[id]/page.tsx b/src/app/[locale]/(protected)/sales/client-management-sales-admin/[id]/page.tsx index 7ca7884c..bc49a1a0 100644 --- a/src/app/[locale]/(protected)/sales/client-management-sales-admin/[id]/page.tsx +++ b/src/app/[locale]/(protected)/sales/client-management-sales-admin/[id]/page.tsx @@ -1,124 +1,20 @@ +'use client'; + /** - * 거래처 상세 페이지 + * 거래처(영업) 상세/수정 페이지 + * IntegratedDetailTemplate V2 마이그레이션 + * + * URL 패턴: + * - /[id] : view 모드 + * - /[id]?mode=edit : edit 모드 */ -"use client"; - -import { useEffect, useState } from "react"; -import { useRouter, useParams } from "next/navigation"; -import { ClientDetail } from "@/components/clients/ClientDetail"; -import { useClientList, Client } from "@/hooks/useClientList"; -import { toast } from "sonner"; -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from "@/components/ui/alert-dialog"; -import { ContentLoadingSpinner } from "@/components/ui/loading-spinner"; +import { useParams } from 'next/navigation'; +import { ClientDetailClientV2 } from '@/components/clients/ClientDetailClientV2'; export default function ClientDetailPage() { - const router = useRouter(); const params = useParams(); const id = params.id as string; - const { fetchClient, deleteClient } = useClientList(); - const [client, setClient] = useState(null); - const [isLoading, setIsLoading] = useState(true); - const [isDeleting, setIsDeleting] = useState(false); - const [showDeleteDialog, setShowDeleteDialog] = useState(false); - - // 데이터 로드 - useEffect(() => { - const loadClient = async () => { - if (!id) return; - - setIsLoading(true); - try { - const data = await fetchClient(id); - if (data) { - setClient(data); - } else { - toast.error("거래처를 찾을 수 없습니다."); - router.push("/sales/client-management-sales-admin"); - } - } catch (error) { - toast.error("데이터 로드 중 오류가 발생했습니다."); - router.push("/sales/client-management-sales-admin"); - } finally { - setIsLoading(false); - } - }; - - loadClient(); - }, [id, fetchClient, router]); - - const handleBack = () => { - router.push("/sales/client-management-sales-admin"); - }; - - const handleEdit = () => { - router.push(`/sales/client-management-sales-admin/${id}/edit`); - }; - - const handleDelete = async () => { - setIsDeleting(true); - try { - await deleteClient(id); - toast.success("거래처가 삭제되었습니다."); - router.push("/sales/client-management-sales-admin"); - } catch (error) { - toast.error("삭제 중 오류가 발생했습니다."); - } finally { - setIsDeleting(false); - setShowDeleteDialog(false); - } - }; - - if (isLoading) { - return ; - } - - if (!client) { - return null; - } - - return ( - <> - setShowDeleteDialog(true)} - /> - - {/* 삭제 확인 다이얼로그 */} - - - - 거래처 삭제 - - '{client.name}' 거래처를 삭제하시겠습니까? -
- 이 작업은 되돌릴 수 없습니다. -
-
- - 취소 - - {isDeleting ? "삭제 중..." : "삭제"} - - -
-
- - ); -} \ No newline at end of file + return ; +} diff --git a/src/app/[locale]/(protected)/sales/client-management-sales-admin/new/page.tsx b/src/app/[locale]/(protected)/sales/client-management-sales-admin/new/page.tsx index 3161845d..985dfa7d 100644 --- a/src/app/[locale]/(protected)/sales/client-management-sales-admin/new/page.tsx +++ b/src/app/[locale]/(protected)/sales/client-management-sales-admin/new/page.tsx @@ -1,30 +1,12 @@ +'use client'; + /** - * 거래처 등록 페이지 + * 거래처(영업) 등록 페이지 + * IntegratedDetailTemplate V2 마이그레이션 */ -"use client"; - -import { useRouter } from "next/navigation"; -import { ClientRegistration } from "@/components/clients/ClientRegistration"; -import { useClientList, ClientFormData } from "@/hooks/useClientList"; +import { ClientDetailClientV2 } from '@/components/clients/ClientDetailClientV2'; export default function ClientNewPage() { - const router = useRouter(); - const { createClient, isLoading } = useClientList(); - - const handleBack = () => { - router.push("/sales/client-management-sales-admin"); - }; - - const handleSave = async (formData: ClientFormData) => { - await createClient(formData); - }; - - return ( - - ); -} \ No newline at end of file + return ; +} diff --git a/src/app/[locale]/(protected)/sales/quote-management/[id]/edit/page.tsx b/src/app/[locale]/(protected)/sales/quote-management/[id]/edit/page.tsx index 54f9e786..efec5791 100644 --- a/src/app/[locale]/(protected)/sales/quote-management/[id]/edit/page.tsx +++ b/src/app/[locale]/(protected)/sales/quote-management/[id]/edit/page.tsx @@ -1,100 +1,27 @@ +'use client'; + +import { use, useEffect } from 'react'; +import { useRouter } from 'next/navigation'; + +interface EditQuotePageProps { + params: Promise<{ id: string }>; +} + /** - * 견적 수정 페이지 + * 하위 호환성을 위한 리다이렉트 페이지 + * /quote-management/[id]/edit → /quote-management/[id]?mode=edit */ - -"use client"; - -import { useRouter, useParams } from "next/navigation"; -import { useState, useEffect, useCallback } from "react"; -import { QuoteRegistration, QuoteFormData } from "@/components/quotes/QuoteRegistration"; -import { - getQuoteById, - updateQuote, - transformQuoteToFormData, - transformFormDataToApi, -} from "@/components/quotes"; -import { toast } from "sonner"; -import { ContentLoadingSpinner } from "@/components/ui/loading-spinner"; - -export default function QuoteEditPage() { +export default function EditQuotePage({ params }: EditQuotePageProps) { + const { id } = use(params); const router = useRouter(); - const params = useParams(); - const quoteId = params.id as string; - - const [quote, setQuote] = useState(null); - const [isLoading, setIsLoading] = useState(true); - const [isSaving, setIsSaving] = useState(false); - - // 견적 데이터 조회 - const fetchQuote = useCallback(async () => { - setIsLoading(true); - try { - const result = await getQuoteById(quoteId); - console.log('[EditPage] API 응답 result.data:', JSON.stringify({ - calculationInputs: result.data?.calculationInputs, - items: result.data?.items?.map(i => ({ quantity: i.quantity, unitPrice: i.unitPrice })) - }, null, 2)); - - if (result.success && result.data) { - const formData = transformQuoteToFormData(result.data); - console.log('[EditPage] 변환된 formData.items[0]:', JSON.stringify({ - quantity: formData.items[0]?.quantity, - wingSize: formData.items[0]?.wingSize, - inspectionFee: formData.items[0]?.inspectionFee, - }, null, 2)); - setQuote(formData); - } else { - toast.error(result.error || "견적 정보를 불러오는데 실패했습니다."); - router.push("/sales/quote-management"); - } - } catch (error) { - toast.error("견적 정보를 불러오는데 실패했습니다."); - router.push("/sales/quote-management"); - } finally { - setIsLoading(false); - } - }, [quoteId, router]); useEffect(() => { - fetchQuote(); - }, [fetchQuote]); - - const handleBack = () => { - router.push("/sales/quote-management"); - }; - - const handleSave = async (formData: QuoteFormData) => { - if (isSaving) return; - setIsSaving(true); - - try { - // FormData를 API 요청 형식으로 변환 - const apiData = transformFormDataToApi(formData); - - const result = await updateQuote(quoteId, apiData as any); - - if (result.success) { - toast.success("견적이 수정되었습니다."); - router.push(`/sales/quote-management/${quoteId}`); - } else { - toast.error(result.error || "견적 수정에 실패했습니다."); - } - } catch (error) { - toast.error("견적 수정에 실패했습니다."); - } finally { - setIsSaving(false); - } - }; - - if (isLoading) { - return ; - } + router.replace(`/ko/sales/quote-management/${id}?mode=edit`); + }, [id, router]); return ( - +
+
리다이렉트 중...
+
); } diff --git a/src/app/[locale]/(protected)/sales/quote-management/[id]/page.tsx b/src/app/[locale]/(protected)/sales/quote-management/[id]/page.tsx index c688a319..2ffe607b 100644 --- a/src/app/[locale]/(protected)/sales/quote-management/[id]/page.tsx +++ b/src/app/[locale]/(protected)/sales/quote-management/[id]/page.tsx @@ -1,15 +1,20 @@ /** - * 견적 상세 페이지 - * - 기본 정보 표시 + * 견적 상세/수정 페이지 (V2 통합) + * - 기본 정보 표시 (view mode) * - 자동 견적 산출 정보 * - 견적서 / 산출내역서 / 발주서 모달 + * - 수정 모드 (edit mode) + * + * URL 패턴: + * - /quote-management/[id] → 상세 보기 (view) + * - /quote-management/[id]?mode=edit → 수정 모드 (edit) */ "use client"; -import { useRouter, useParams } from "next/navigation"; +import { useRouter, useParams, useSearchParams } from "next/navigation"; import { useState, useEffect, useCallback } from "react"; -import { QuoteFormData } from "@/components/quotes/QuoteRegistration"; +import { QuoteRegistration, QuoteFormData } from "@/components/quotes/QuoteRegistration"; import { QuoteDocument } from "@/components/quotes/QuoteDocument"; import { QuoteCalculationReport } from "@/components/quotes/QuoteCalculationReport"; import { PurchaseOrderDocument } from "@/components/quotes/PurchaseOrderDocument"; @@ -20,6 +25,8 @@ import { sendQuoteEmail, sendQuoteKakao, transformQuoteToFormData, + updateQuote, + transformFormDataToApi, } from "@/components/quotes"; import { getCompanyInfo } from "@/components/settings/CompanyInfoManagement/actions"; import type { CompanyFormData } from "@/components/settings/CompanyInfoManagement/types"; @@ -56,12 +63,18 @@ import { ContentLoadingSpinner } from "@/components/ui/loading-spinner"; export default function QuoteDetailPage() { const router = useRouter(); const params = useParams(); + const searchParams = useSearchParams(); const quoteId = params.id as string; + // V2 패턴: mode 체크 + const mode = searchParams.get("mode") || "view"; + const isEditMode = mode === "edit"; + const [quote, setQuote] = useState(null); const [companyInfo, setCompanyInfo] = useState(null); const [isLoading, setIsLoading] = useState(true); const [isProcessing, setIsProcessing] = useState(false); + const [isSaving, setIsSaving] = useState(false); // 다이얼로그 상태 const [isQuoteDocumentOpen, setIsQuoteDocumentOpen] = useState(false); @@ -143,7 +156,29 @@ export default function QuoteDetailPage() { }; const handleEdit = () => { - router.push(`/sales/quote-management/${quoteId}/edit`); + router.push(`/sales/quote-management/${quoteId}?mode=edit`); + }; + + // V2 패턴: 수정 저장 핸들러 + const handleSave = async (formData: QuoteFormData) => { + if (isSaving) return; + setIsSaving(true); + + try { + const apiData = transformFormDataToApi(formData); + const result = await updateQuote(quoteId, apiData as any); + + if (result.success) { + toast.success("견적이 수정되었습니다."); + router.push(`/sales/quote-management/${quoteId}`); + } else { + toast.error(result.error || "견적 수정에 실패했습니다."); + } + } catch (error) { + toast.error("견적 수정에 실패했습니다."); + } finally { + setIsSaving(false); + } }; const handleFinalize = async () => { @@ -261,6 +296,18 @@ export default function QuoteDetailPage() { ); } + // V2 패턴: Edit 모드일 때 QuoteRegistration 컴포넌트 렌더링 + if (isEditMode) { + return ( + + ); + } + + // View 모드: 상세 보기 return (
{/* 헤더 */} diff --git a/src/app/[locale]/(protected)/sales/quote-management/test/[id]/edit/page.tsx b/src/app/[locale]/(protected)/sales/quote-management/test/[id]/edit/page.tsx index afb22c59..df6e60e3 100644 --- a/src/app/[locale]/(protected)/sales/quote-management/test/[id]/edit/page.tsx +++ b/src/app/[locale]/(protected)/sales/quote-management/test/[id]/edit/page.tsx @@ -1,152 +1,27 @@ +'use client'; + +import { use, useEffect } from 'react'; +import { useRouter } from 'next/navigation'; + +interface EditQuoteTestPageProps { + params: Promise<{ id: string }>; +} + /** - * 견적 수정 테스트 페이지 (V2 UI) - * - * 새로운 자동 견적 산출 UI 테스트용 - * 기존 견적 수정 페이지는 수정하지 않음 + * 하위 호환성을 위한 리다이렉트 페이지 + * /quote-management/test/[id]/edit → /quote-management/test/[id]?mode=edit */ - -"use client"; - -import { useRouter, useParams } from "next/navigation"; -import { useState, useEffect } from "react"; -import { QuoteRegistrationV2, QuoteFormDataV2 } from "@/components/quotes/QuoteRegistrationV2"; -import { ContentLoadingSpinner } from "@/components/ui/loading-spinner"; -import { toast } from "sonner"; - -// 테스트용 목업 데이터 -const MOCK_DATA: QuoteFormDataV2 = { - id: "1", - registrationDate: "2026-01-12", - writer: "드미트리", - clientId: "1", - clientName: "아크다이레드", - siteName: "강남 테스트 현장", - manager: "김담당", - contact: "010-1234-5678", - dueDate: "2026-02-01", - remarks: "테스트 비고 내용입니다.", - status: "draft", - locations: [ - { - id: "loc-1", - floor: "1층", - code: "FSS-01", - openWidth: 5000, - openHeight: 3000, - productCode: "KSS01", - productName: "방화스크린", - quantity: 1, - guideRailType: "wall", - motorPower: "single", - controller: "basic", - wingSize: 50, - inspectionFee: 50000, - unitPrice: 1645200, - totalPrice: 1645200, - }, - { - id: "loc-2", - floor: "3층", - code: "FST-30", - openWidth: 7500, - openHeight: 3300, - productCode: "KSS02", - productName: "방화스크린2", - quantity: 1, - guideRailType: "wall", - motorPower: "single", - controller: "smart", - wingSize: 50, - inspectionFee: 50000, - unitPrice: 2589198, - totalPrice: 2589198, - }, - { - id: "loc-3", - floor: "5층", - code: "FSS-50", - openWidth: 6000, - openHeight: 2800, - productCode: "KSS01", - productName: "방화스크린", - quantity: 2, - guideRailType: "floor", - motorPower: "three", - controller: "premium", - wingSize: 50, - inspectionFee: 50000, - unitPrice: 1721214, - totalPrice: 3442428, - }, - ], -}; - -export default function QuoteTestEditPage() { +export default function EditQuoteTestPage({ params }: EditQuoteTestPageProps) { + const { id } = use(params); const router = useRouter(); - const params = useParams(); - const quoteId = params.id as string; - - const [quote, setQuote] = useState(null); - const [isLoading, setIsLoading] = useState(true); - const [isSaving, setIsSaving] = useState(false); useEffect(() => { - // 테스트용 데이터 로드 시뮬레이션 - const loadQuote = async () => { - setIsLoading(true); - try { - // 실제로는 API 호출 - await new Promise((resolve) => setTimeout(resolve, 500)); - setQuote({ ...MOCK_DATA, id: quoteId }); - } catch (error) { - toast.error("견적 정보를 불러오는데 실패했습니다."); - router.push("/sales/quote-management"); - } finally { - setIsLoading(false); - } - }; - - loadQuote(); - }, [quoteId, router]); - - const handleBack = () => { - router.push("/sales/quote-management"); - }; - - const handleSave = async (data: QuoteFormDataV2, saveType: "temporary" | "final") => { - setIsSaving(true); - try { - // TODO: API 연동 시 실제 저장 로직 구현 - console.log("[테스트] 수정 데이터:", data); - console.log("[테스트] 저장 타입:", saveType); - - // 테스트용 지연 - await new Promise((resolve) => setTimeout(resolve, 1000)); - - toast.success(`[테스트] ${saveType === "temporary" ? "임시" : "최종"} 저장 완료`); - - // 저장 후 상세 페이지로 이동 - if (saveType === "final") { - router.push(`/sales/quote-management/test/${quoteId}`); - } - } catch (error) { - toast.error("저장 중 오류가 발생했습니다."); - } finally { - setIsSaving(false); - } - }; - - if (isLoading) { - return ; - } + router.replace(`/ko/sales/quote-management/test/${id}?mode=edit`); + }, [id, router]); return ( - +
+
리다이렉트 중...
+
); } diff --git a/src/app/[locale]/(protected)/sales/quote-management/test/[id]/page.tsx b/src/app/[locale]/(protected)/sales/quote-management/test/[id]/page.tsx index 4e8eadb6..656c81fd 100644 --- a/src/app/[locale]/(protected)/sales/quote-management/test/[id]/page.tsx +++ b/src/app/[locale]/(protected)/sales/quote-management/test/[id]/page.tsx @@ -1,13 +1,15 @@ /** - * 견적 상세 테스트 페이지 (V2 UI) + * 견적 상세/수정 테스트 페이지 (V2 UI 통합) * * 새로운 자동 견적 산출 UI 테스트용 - * 기존 견적 상세 페이지는 수정하지 않음 + * URL 패턴: + * - /quote-management/test/[id] → 상세 보기 (view) + * - /quote-management/test/[id]?mode=edit → 수정 모드 (edit) */ "use client"; -import { useRouter, useParams } from "next/navigation"; +import { useRouter, useParams, useSearchParams } from "next/navigation"; import { useState, useEffect } from "react"; import { QuoteRegistrationV2, QuoteFormDataV2, LocationItem } from "@/components/quotes/QuoteRegistrationV2"; import { ContentLoadingSpinner } from "@/components/ui/loading-spinner"; @@ -84,10 +86,16 @@ const MOCK_DATA: QuoteFormDataV2 = { export default function QuoteTestDetailPage() { const router = useRouter(); const params = useParams(); + const searchParams = useSearchParams(); const quoteId = params.id as string; + // V2 패턴: mode 체크 + const mode = searchParams.get("mode") || "view"; + const isEditMode = mode === "edit"; + const [quote, setQuote] = useState(null); const [isLoading, setIsLoading] = useState(true); + const [isSaving, setIsSaving] = useState(false); useEffect(() => { // 테스트용 데이터 로드 시뮬레이션 @@ -112,15 +120,42 @@ export default function QuoteTestDetailPage() { router.push("/sales/quote-management"); }; + // V2 패턴: 수정 저장 핸들러 + const handleSave = async (data: QuoteFormDataV2, saveType: "temporary" | "final") => { + setIsSaving(true); + try { + // TODO: API 연동 시 실제 저장 로직 구현 + console.log("[테스트] 수정 데이터:", data); + console.log("[테스트] 저장 타입:", saveType); + + // 테스트용 지연 + await new Promise((resolve) => setTimeout(resolve, 1000)); + + toast.success(`[테스트] ${saveType === "temporary" ? "임시" : "최종"} 저장 완료`); + + // 저장 후 상세 페이지로 이동 + if (saveType === "final") { + router.push(`/sales/quote-management/test/${quoteId}`); + } + } catch (error) { + toast.error("저장 중 오류가 발생했습니다."); + } finally { + setIsSaving(false); + } + }; + if (isLoading) { return ; } + // V2 패턴: mode에 따라 view/edit 렌더링 return ( ); } diff --git a/src/app/[locale]/(protected)/settings/popup-management/[id]/edit/page.tsx b/src/app/[locale]/(protected)/settings/popup-management/[id]/edit/page.tsx index d89ec276..8762b5c2 100644 --- a/src/app/[locale]/(protected)/settings/popup-management/[id]/edit/page.tsx +++ b/src/app/[locale]/(protected)/settings/popup-management/[id]/edit/page.tsx @@ -1,38 +1,24 @@ 'use client'; -import { useParams } from 'next/navigation'; -import { useState, useEffect } from 'react'; -import { PopupForm } from '@/components/settings/PopupManagement'; -import { getPopupById } from '@/components/settings/PopupManagement/actions'; -import type { Popup } from '@/components/settings/PopupManagement/types'; +/** + * 팝업관리 수정 페이지 (리다이렉트) + * + * 기존 URL 호환성을 위해 유지 + * /[id]/edit → /[id]?mode=edit 로 리다이렉트 + */ + +import { useEffect } from 'react'; +import { useParams, useRouter } from 'next/navigation'; import { ContentLoadingSpinner } from '@/components/ui/loading-spinner'; -export default function PopupEditPage() { +export default function PopupEditRedirectPage() { + const router = useRouter(); const params = useParams(); const id = params.id as string; - const [popup, setPopup] = useState(null); - const [isLoading, setIsLoading] = useState(true); useEffect(() => { - const fetchPopup = async () => { - const data = await getPopupById(id); - setPopup(data); - setIsLoading(false); - }; - fetchPopup(); - }, [id]); + router.replace(`/ko/settings/popup-management/${id}?mode=edit`); + }, [router, id]); - if (isLoading) { - return ; - } - - if (!popup) { - return ( -
-

팝업을 찾을 수 없습니다.

-
- ); - } - - return ; -} \ No newline at end of file + return ; +} diff --git a/src/app/[locale]/(protected)/settings/popup-management/[id]/page.tsx b/src/app/[locale]/(protected)/settings/popup-management/[id]/page.tsx index 97086df9..3f33e50c 100644 --- a/src/app/[locale]/(protected)/settings/popup-management/[id]/page.tsx +++ b/src/app/[locale]/(protected)/settings/popup-management/[id]/page.tsx @@ -1,93 +1,19 @@ 'use client'; -import { useRouter, useParams } from 'next/navigation'; -import { useState, useEffect } from 'react'; -import { PopupDetail } from '@/components/settings/PopupManagement'; -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from '@/components/ui/alert-dialog'; -import { ContentLoadingSpinner } from '@/components/ui/loading-spinner'; -import { getPopupById, deletePopup } from '@/components/settings/PopupManagement/actions'; -import type { Popup } from '@/components/settings/PopupManagement/types'; +/** + * 팝업관리 상세/수정 페이지 + * + * IntegratedDetailTemplate V2 마이그레이션 + * - /[id] → 상세 보기 (view 모드) + * - /[id]?mode=edit → 수정 (edit 모드) + */ + +import { useParams } from 'next/navigation'; +import { PopupDetailClientV2 } from '@/components/settings/PopupManagement/PopupDetailClientV2'; export default function PopupDetailPage() { - const router = useRouter(); const params = useParams(); - const [popup, setPopup] = useState(null); - const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); - const [isDeleting, setIsDeleting] = useState(false); + const id = params.id as string; - useEffect(() => { - const fetchPopup = async () => { - const id = params.id as string; - const data = await getPopupById(id); - setPopup(data); - }; - fetchPopup(); - }, [params.id]); - - const handleEdit = () => { - router.push(`/ko/settings/popup-management/${params.id}/edit`); - }; - - const handleDelete = () => { - setDeleteDialogOpen(true); - }; - - const confirmDelete = async () => { - setIsDeleting(true); - const result = await deletePopup(params.id as string); - if (result.success) { - router.push('/ko/settings/popup-management'); - } else { - console.error('Delete failed:', result.error); - setIsDeleting(false); - } - }; - - if (!popup) { - return ; - } - - return ( - <> - - - - - - 팝업 삭제 - - "{popup.title}" 팝업을 삭제하시겠습니까? -
- - 삭제된 팝업 정보는 복구할 수 없습니다. - -
-
- - 취소 - - {isDeleting ? '삭제 중...' : '삭제'} - - -
-
- - ); + return ; } diff --git a/src/app/[locale]/(protected)/settings/popup-management/new/page.tsx b/src/app/[locale]/(protected)/settings/popup-management/new/page.tsx index ab40d806..a97021ac 100644 --- a/src/app/[locale]/(protected)/settings/popup-management/new/page.tsx +++ b/src/app/[locale]/(protected)/settings/popup-management/new/page.tsx @@ -1,5 +1,13 @@ -import { PopupForm } from '@/components/settings/PopupManagement'; +'use client'; + +/** + * 팝업관리 등록 페이지 + * + * IntegratedDetailTemplate V2 마이그레이션 + */ + +import { PopupDetailClientV2 } from '@/components/settings/PopupManagement/PopupDetailClientV2'; export default function PopupCreatePage() { - return ; + return ; } diff --git a/src/app/api/proxy/[...path]/route.ts b/src/app/api/proxy/[...path]/route.ts index cad50e86..19a71d59 100644 --- a/src/app/api/proxy/[...path]/route.ts +++ b/src/app/api/proxy/[...path]/route.ts @@ -132,9 +132,20 @@ async function proxyRequest( method: string ) { try { - // 1. HttpOnly 쿠키에서 토큰 읽기 (서버에서만 가능!) - let token = request.cookies.get('access_token')?.value; - const refreshToken = request.cookies.get('refresh_token')?.value; + // 1. 🆕 미들웨어에서 전달한 새 토큰 먼저 확인 + // Set-Cookie는 응답 헤더에만 설정되어 같은 요청 내 cookies()로 읽을 수 없음 + // 따라서 request headers로 전달된 새 토큰을 먼저 사용 + const refreshedAccessToken = request.headers.get('x-refreshed-access-token'); + const refreshedRefreshToken = request.headers.get('x-refreshed-refresh-token'); + + // 2. HttpOnly 쿠키에서 토큰 읽기 (서버에서만 가능!) + let token = refreshedAccessToken || request.cookies.get('access_token')?.value; + const refreshToken = refreshedRefreshToken || request.cookies.get('refresh_token')?.value; + + // 디버깅: 어떤 토큰을 사용하는지 로그 + if (refreshedAccessToken) { + console.log('🔵 [PROXY] Using refreshed token from middleware headers'); + } // 2. 백엔드 URL 구성 const backendUrl = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/${params.path.join('/')}`; diff --git a/src/components/accounting/BadDebtCollection/BadDebtDetail.tsx b/src/components/accounting/BadDebtCollection/BadDebtDetail.tsx index 5197cb72..c81089f2 100644 --- a/src/components/accounting/BadDebtCollection/BadDebtDetail.tsx +++ b/src/components/accounting/BadDebtCollection/BadDebtDetail.tsx @@ -135,7 +135,7 @@ export function BadDebtDetail({ mode, recordId, initialData }: BadDebtDetailProp }, [router]); const handleEdit = useCallback(() => { - router.push(`/ko/accounting/bad-debt-collection/${recordId}/edit`); + router.push(`/ko/accounting/bad-debt-collection/${recordId}?mode=edit`); }, [router, recordId]); const handleCancel = useCallback(() => { diff --git a/src/components/accounting/BadDebtCollection/BadDebtDetailClientV2.tsx b/src/components/accounting/BadDebtCollection/BadDebtDetailClientV2.tsx new file mode 100644 index 00000000..7921fba1 --- /dev/null +++ b/src/components/accounting/BadDebtCollection/BadDebtDetailClientV2.tsx @@ -0,0 +1,135 @@ +'use client'; + +/** + * 대손추심 상세 클라이언트 컴포넌트 V2 + * + * 라우팅 구조 변경: /[id], /[id]/edit, /new → /[id]?mode=view|edit, /new + * 기존 BadDebtDetail 컴포넌트 활용 + */ + +import { useState, useEffect } from 'react'; +import { useRouter, useSearchParams } from 'next/navigation'; +import { BadDebtDetail } from './BadDebtDetail'; +import { getBadDebtById } from './actions'; +import type { BadDebtRecord } from './types'; +import { ContentLoadingSpinner } from '@/components/ui/loading-spinner'; +import { ErrorCard } from '@/components/ui/error-card'; +import { toast } from 'sonner'; + +type DetailMode = 'view' | 'edit' | 'new'; + +interface BadDebtDetailClientV2Props { + recordId?: string; + initialMode?: DetailMode; +} + +const BASE_PATH = '/ko/accounting/bad-debt-collection'; + +export function BadDebtDetailClientV2({ recordId, initialMode }: BadDebtDetailClientV2Props) { + const router = useRouter(); + const searchParams = useSearchParams(); + + // URL 쿼리에서 모드 결정 + const modeFromQuery = searchParams.get('mode') as DetailMode | null; + const isNewMode = !recordId || recordId === 'new'; + + const [mode, setMode] = useState(() => { + if (isNewMode) return 'new'; + if (initialMode) return initialMode; + if (modeFromQuery === 'edit') return 'edit'; + return 'view'; + }); + + const [recordData, setRecordData] = useState(null); + const [isLoading, setIsLoading] = useState(!isNewMode); + const [error, setError] = useState(null); + + // 데이터 로드 + useEffect(() => { + const loadData = async () => { + if (isNewMode) { + setIsLoading(false); + return; + } + + setIsLoading(true); + setError(null); + + try { + const result = await getBadDebtById(recordId!); + if (result) { + setRecordData(result); + } else { + setError('악성채권 정보를 찾을 수 없습니다.'); + toast.error('악성채권을 불러오는데 실패했습니다.'); + } + } catch (err) { + console.error('악성채권 조회 실패:', err); + setError('악성채권 정보를 불러오는 중 오류가 발생했습니다.'); + toast.error('악성채권을 불러오는데 실패했습니다.'); + } finally { + setIsLoading(false); + } + }; + + loadData(); + }, [recordId, isNewMode]); + + // URL 쿼리 변경 감지 + useEffect(() => { + if (!isNewMode && modeFromQuery === 'edit') { + setMode('edit'); + } else if (!isNewMode && !modeFromQuery) { + setMode('view'); + } + }, [modeFromQuery, isNewMode]); + + // 로딩 중 + if (isLoading) { + return ; + } + + // 에러 발생 (view/edit 모드에서) + if (error && !isNewMode) { + return ( + + ); + } + + // 등록 모드 + if (mode === 'new') { + return ; + } + + // 수정 모드 + if (mode === 'edit' && recordData) { + return ; + } + + // 상세 보기 모드 + if (mode === 'view' && recordData) { + return ; + } + + // 데이터 없음 (should not reach here) + return ( + + ); +} diff --git a/src/components/accounting/BadDebtCollection/index.tsx b/src/components/accounting/BadDebtCollection/index.tsx index a3cb1959..f3c63e00 100644 --- a/src/components/accounting/BadDebtCollection/index.tsx +++ b/src/components/accounting/BadDebtCollection/index.tsx @@ -1,5 +1,7 @@ 'use client'; +export { BadDebtDetailClientV2 } from './BadDebtDetailClientV2'; + /** * 악성채권 추심관리 - UniversalListPage 마이그레이션 * @@ -103,7 +105,7 @@ export function BadDebtCollection({ initialData, initialSummary }: BadDebtCollec const handleEdit = useCallback( (item: BadDebtRecord) => { - router.push(`/ko/accounting/bad-debt-collection/${item.id}/edit`); + router.push(`/ko/accounting/bad-debt-collection/${item.id}?mode=edit`); }, [router] ); diff --git a/src/components/accounting/BillManagement/BillDetail.tsx b/src/components/accounting/BillManagement/BillDetail.tsx index 8ed41e46..63844b65 100644 --- a/src/components/accounting/BillManagement/BillDetail.tsx +++ b/src/components/accounting/BillManagement/BillDetail.tsx @@ -333,7 +333,7 @@ export function BillDetail({ billId, mode }: BillDetailProps) { - + {clients.map((client) => ( @@ -410,7 +410,7 @@ export function BillDetail({ billId, mode }: BillDetailProps) { - + {vendors.map((vendor) => ( @@ -307,7 +307,7 @@ export function DepositDetail({ depositId, mode }: DepositDetailProps) { setSalesType(v as SalesType)} disabled={isViewMode}> - + {SALES_TYPE_OPTIONS.filter(o => o.value !== 'all').map((option) => ( diff --git a/src/components/accounting/WithdrawalManagement/WithdrawalDetail.tsx b/src/components/accounting/WithdrawalManagement/WithdrawalDetail.tsx index 56d3ec5c..2088dc57 100644 --- a/src/components/accounting/WithdrawalManagement/WithdrawalDetail.tsx +++ b/src/components/accounting/WithdrawalManagement/WithdrawalDetail.tsx @@ -288,7 +288,7 @@ export function WithdrawalDetail({ withdrawalId, mode }: WithdrawalDetailProps) setWithdrawalType(v as WithdrawalType)} disabled={isViewMode}> - + {WITHDRAWAL_TYPE_SELECTOR_OPTIONS.map((option) => ( diff --git a/src/components/accounting/WithdrawalManagement/WithdrawalDetailClientV2.tsx b/src/components/accounting/WithdrawalManagement/WithdrawalDetailClientV2.tsx new file mode 100644 index 00000000..8946dfa7 --- /dev/null +++ b/src/components/accounting/WithdrawalManagement/WithdrawalDetailClientV2.tsx @@ -0,0 +1,120 @@ +'use client'; + +import { useState, useCallback, useEffect } from 'react'; +import { useRouter } from 'next/navigation'; +import { toast } from 'sonner'; +import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate'; +import type { DetailMode } from '@/components/templates/IntegratedDetailTemplate/types'; +import { withdrawalDetailConfig } from './withdrawalDetailConfig'; +import type { WithdrawalRecord } from './types'; +import { + getWithdrawalById, + createWithdrawal, + updateWithdrawal, + deleteWithdrawal, +} from './actions'; + +// ===== Props ===== +interface WithdrawalDetailClientV2Props { + withdrawalId?: string; + initialMode?: DetailMode; +} + +export default function WithdrawalDetailClientV2({ + withdrawalId, + initialMode = 'view', +}: WithdrawalDetailClientV2Props) { + const router = useRouter(); + const [mode, setMode] = useState(initialMode); + const [withdrawal, setWithdrawal] = useState(null); + const [isLoading, setIsLoading] = useState(initialMode !== 'create'); + + // ===== 데이터 로드 ===== + useEffect(() => { + const loadWithdrawal = async () => { + if (withdrawalId && initialMode !== 'create') { + setIsLoading(true); + const result = await getWithdrawalById(withdrawalId); + if (result.success && result.data) { + setWithdrawal(result.data); + } else { + toast.error(result.error || '출금 내역을 불러오는데 실패했습니다.'); + } + setIsLoading(false); + } + }; + loadWithdrawal(); + }, [withdrawalId, initialMode]); + + // ===== 저장/등록 핸들러 ===== + const handleSubmit = useCallback( + async (formData: Record): Promise<{ success: boolean; error?: string }> => { + const submitData = withdrawalDetailConfig.transformSubmitData?.(formData) || formData; + + if (!submitData.vendorId) { + toast.error('거래처를 선택해주세요.'); + return { success: false, error: '거래처를 선택해주세요.' }; + } + if (submitData.withdrawalType === 'unset') { + toast.error('출금 유형을 선택해주세요.'); + return { success: false, error: '출금 유형을 선택해주세요.' }; + } + + const result = + mode === 'create' + ? await createWithdrawal(submitData as Partial) + : await updateWithdrawal(withdrawalId!, submitData as Partial); + + if (result.success) { + toast.success(mode === 'create' ? '출금 내역이 등록되었습니다.' : '출금 내역이 수정되었습니다.'); + router.push('/ko/accounting/withdrawals'); + return { success: true }; + } else { + toast.error(result.error || '저장에 실패했습니다.'); + return { success: false, error: result.error }; + } + }, + [mode, withdrawalId, router] + ); + + // ===== 삭제 핸들러 ===== + const handleDelete = useCallback(async (): Promise<{ success: boolean; error?: string }> => { + if (!withdrawalId) return { success: false, error: 'ID가 없습니다.' }; + + const result = await deleteWithdrawal(withdrawalId); + if (result.success) { + toast.success('출금 내역이 삭제되었습니다.'); + router.push('/ko/accounting/withdrawals'); + return { success: true }; + } else { + toast.error(result.error || '삭제에 실패했습니다.'); + return { success: false, error: result.error }; + } + }, [withdrawalId, router]); + + // ===== 모드 변경 핸들러 ===== + const handleModeChange = useCallback( + (newMode: DetailMode) => { + if (newMode === 'edit' && withdrawalId) { + router.push(`/ko/accounting/withdrawals/${withdrawalId}/edit`); + } else { + setMode(newMode); + } + }, + [withdrawalId, router] + ); + + return ( + [0]['config']} + mode={mode} + initialData={withdrawal as unknown as Record | undefined} + itemId={withdrawalId} + isLoading={isLoading} + onSubmit={handleSubmit} + onDelete={handleDelete} + onModeChange={handleModeChange} + buttonPosition="top" + /> + ); +} diff --git a/src/components/accounting/WithdrawalManagement/withdrawalDetailConfig.ts b/src/components/accounting/WithdrawalManagement/withdrawalDetailConfig.ts new file mode 100644 index 00000000..e2a6af28 --- /dev/null +++ b/src/components/accounting/WithdrawalManagement/withdrawalDetailConfig.ts @@ -0,0 +1,123 @@ +import { Banknote } from 'lucide-react'; +import type { DetailConfig, FieldDefinition } from '@/components/templates/IntegratedDetailTemplate/types'; +import type { WithdrawalRecord } from './types'; +import { WITHDRAWAL_TYPE_SELECTOR_OPTIONS } from './types'; +import { getVendors } from './actions'; + +// ===== 필드 정의 ===== +const fields: FieldDefinition[] = [ + // 출금일 (readonly) + { + key: 'withdrawalDate', + label: '출금일', + type: 'text', + readonly: true, + placeholder: '-', + }, + // 출금계좌 (readonly) + { + key: 'accountName', + label: '출금계좌', + type: 'text', + readonly: true, + placeholder: '-', + }, + // 수취인명 (readonly) + { + key: 'recipientName', + label: '수취인명', + type: 'text', + readonly: true, + placeholder: '-', + }, + // 출금금액 (readonly) + { + key: 'withdrawalAmount', + label: '출금금액', + type: 'text', + readonly: true, + placeholder: '-', + }, + // 적요 (editable) + { + key: 'note', + label: '적요', + type: 'text', + placeholder: '적요를 입력해주세요', + gridSpan: 2, + disabled: (mode) => mode === 'view', + }, + // 거래처 (editable, required) + { + key: 'vendorId', + label: '거래처', + type: 'select', + required: true, + placeholder: '선택', + fetchOptions: async () => { + const result = await getVendors(); + if (result.success) { + return result.data.map((v) => ({ + value: v.id, + label: v.name, + })); + } + return []; + }, + disabled: (mode) => mode === 'view', + }, + // 출금 유형 (editable, required) + { + key: 'withdrawalType', + label: '출금 유형', + type: 'select', + required: true, + placeholder: '선택', + options: WITHDRAWAL_TYPE_SELECTOR_OPTIONS.map((opt) => ({ + value: opt.value, + label: opt.label, + })), + disabled: (mode) => mode === 'view', + }, +]; + +// ===== Config 정의 ===== +export const withdrawalDetailConfig: DetailConfig = { + title: '출금', + description: '출금 상세 내역을 등록합니다', + icon: Banknote, + basePath: '/accounting/withdrawals', + fields, + gridColumns: 2, + actions: { + showBack: true, + showDelete: true, + showEdit: true, + backLabel: '목록', + deleteLabel: '삭제', + editLabel: '수정', + deleteConfirmMessage: { + title: '출금 삭제', + description: '이 출금 내역을 삭제하시겠습니까? 삭제된 데이터는 복구할 수 없습니다.', + }, + }, + transformInitialData: (data: Record): Record => { + const record = data as unknown as WithdrawalRecord; + return { + withdrawalDate: record.withdrawalDate || '', + accountName: record.accountName || '', + recipientName: record.recipientName || '', + withdrawalAmount: record.withdrawalAmount ? record.withdrawalAmount.toLocaleString() : '0', + note: record.note || '', + vendorId: record.vendorId || '', + withdrawalType: record.withdrawalType || 'unset', + }; + }, + transformSubmitData: (formData: Record): Partial => { + return { + note: formData.note as string, + vendorId: formData.vendorId as string, + withdrawalType: formData.withdrawalType as WithdrawalRecord['withdrawalType'], + }; + }, +}; diff --git a/src/components/board/BoardManagement/BoardDetailClientV2.tsx b/src/components/board/BoardManagement/BoardDetailClientV2.tsx new file mode 100644 index 00000000..58840cd9 --- /dev/null +++ b/src/components/board/BoardManagement/BoardDetailClientV2.tsx @@ -0,0 +1,329 @@ +'use client'; + +/** + * 게시판관리 상세 클라이언트 컴포넌트 V2 + * + * 라우팅 구조 변경: /[id], /[id]/edit, /new → /[id]?mode=view|edit, /new + * 기존 BoardDetail, BoardForm 컴포넌트 활용 + */ + +import { useState, useEffect, useCallback } from 'react'; +import { useRouter, useSearchParams } from 'next/navigation'; +import { Loader2 } from 'lucide-react'; +import { BoardDetail } from './BoardDetail'; +import { BoardForm } from './BoardForm'; +import { getBoardById, createBoard, updateBoard, deleteBoard } from './actions'; +import { forceRefreshMenus } from '@/lib/utils/menuRefresh'; +import type { Board, BoardFormData } from './types'; +import { ContentLoadingSpinner } from '@/components/ui/loading-spinner'; +import { ErrorCard } from '@/components/ui/error-card'; +import { Button } from '@/components/ui/button'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog'; +import { toast } from 'sonner'; + +type DetailMode = 'view' | 'edit' | 'create'; + +interface BoardDetailClientV2Props { + boardId?: string; + initialMode?: DetailMode; +} + +const BASE_PATH = '/ko/board/board-management'; + +// 게시판 코드 생성 (타임스탬프 기반) +const generateBoardCode = (): string => { + const timestamp = Date.now().toString(36); + const random = Math.random().toString(36).substring(2, 6); + return `board_${timestamp}_${random}`; +}; + +export function BoardDetailClientV2({ boardId, initialMode }: BoardDetailClientV2Props) { + const router = useRouter(); + const searchParams = useSearchParams(); + + // URL 쿼리에서 모드 결정 + const modeFromQuery = searchParams.get('mode') as DetailMode | null; + const isNewMode = !boardId || boardId === 'new'; + + const [mode, setMode] = useState(() => { + if (isNewMode) return 'create'; + if (initialMode) return initialMode; + if (modeFromQuery === 'edit') return 'edit'; + return 'view'; + }); + + const [boardData, setBoardData] = useState(null); + const [isLoading, setIsLoading] = useState(!isNewMode); + const [error, setError] = useState(null); + const [isSubmitting, setIsSubmitting] = useState(false); + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const [isDeleting, setIsDeleting] = useState(false); + + // 데이터 로드 + useEffect(() => { + const loadData = async () => { + if (isNewMode) { + setIsLoading(false); + return; + } + + setIsLoading(true); + setError(null); + + try { + const result = await getBoardById(boardId!); + if (result.success && result.data) { + setBoardData(result.data); + } else { + setError(result.error || '게시판 정보를 찾을 수 없습니다.'); + toast.error('게시판을 불러오는데 실패했습니다.'); + } + } catch (err) { + console.error('게시판 조회 실패:', err); + setError('게시판 정보를 불러오는 중 오류가 발생했습니다.'); + toast.error('게시판을 불러오는데 실패했습니다.'); + } finally { + setIsLoading(false); + } + }; + + loadData(); + }, [boardId, isNewMode]); + + // URL 쿼리 변경 감지 + useEffect(() => { + if (!isNewMode && modeFromQuery === 'edit') { + setMode('edit'); + } else if (!isNewMode && !modeFromQuery) { + setMode('view'); + } + }, [modeFromQuery, isNewMode]); + + // 등록 핸들러 + const handleCreate = async (data: BoardFormData) => { + setIsSubmitting(true); + setError(null); + + try { + const result = await createBoard({ + ...data, + boardCode: generateBoardCode(), + }); + + if (result.success && result.data) { + await forceRefreshMenus(); + toast.success('게시판이 등록되었습니다.'); + router.push(BASE_PATH); + } else { + setError(result.error || '게시판 등록에 실패했습니다.'); + toast.error(result.error || '게시판 등록에 실패했습니다.'); + } + } catch (err) { + console.error('게시판 등록 실패:', err); + setError('게시판 등록 중 오류가 발생했습니다.'); + toast.error('게시판 등록 중 오류가 발생했습니다.'); + } finally { + setIsSubmitting(false); + } + }; + + // 수정 핸들러 + const handleUpdate = async (data: BoardFormData) => { + if (!boardData) return; + + setIsSubmitting(true); + setError(null); + + try { + const result = await updateBoard(boardData.id, { + ...data, + boardCode: boardData.boardCode, + description: boardData.description, + }); + + if (result.success) { + await forceRefreshMenus(); + toast.success('게시판이 수정되었습니다.'); + router.push(`${BASE_PATH}/${boardData.id}`); + } else { + setError(result.error || '게시판 수정에 실패했습니다.'); + toast.error(result.error || '게시판 수정에 실패했습니다.'); + } + } catch (err) { + console.error('게시판 수정 실패:', err); + setError('게시판 수정 중 오류가 발생했습니다.'); + toast.error('게시판 수정 중 오류가 발생했습니다.'); + } finally { + setIsSubmitting(false); + } + }; + + // 삭제 핸들러 + const handleDelete = () => { + setDeleteDialogOpen(true); + }; + + const confirmDelete = async () => { + if (!boardData) return; + + setIsDeleting(true); + + try { + const result = await deleteBoard(boardData.id); + + if (result.success) { + await forceRefreshMenus(); + toast.success('게시판이 삭제되었습니다.'); + router.push(BASE_PATH); + } else { + setError(result.error || '삭제에 실패했습니다.'); + toast.error(result.error || '삭제에 실패했습니다.'); + setDeleteDialogOpen(false); + } + } catch (err) { + console.error('게시판 삭제 실패:', err); + setError('게시판 삭제 중 오류가 발생했습니다.'); + toast.error('게시판 삭제 중 오류가 발생했습니다.'); + setDeleteDialogOpen(false); + } finally { + setIsDeleting(false); + } + }; + + // 수정 모드 전환 + const handleEdit = () => { + router.push(`${BASE_PATH}/${boardId}?mode=edit`); + }; + + // 로딩 중 + if (isLoading) { + return ; + } + + // 에러 발생 (view/edit 모드에서) + if (error && !isNewMode) { + return ( + + ); + } + + // 등록 모드 + if (mode === 'create') { + return ( + <> + {error && ( +
+

{error}

+
+ )} + + {isSubmitting && ( +
+
+ + 등록 중... +
+
+ )} + + ); + } + + // 수정 모드 + if (mode === 'edit' && boardData) { + return ( + <> + {error && ( +
+

{error}

+
+ )} + + {isSubmitting && ( +
+
+ + 저장 중... +
+
+ )} + + ); + } + + // 상세 보기 모드 + if (mode === 'view' && boardData) { + return ( + <> + + + + + + 게시판 삭제 + + "{boardData.boardName}" 게시판을 삭제하시겠습니까? +
+ + 삭제된 게시판 정보는 복구할 수 없습니다. + +
+
+ + 취소 + + {isDeleting ? ( + <> + + 삭제 중... + + ) : ( + '삭제' + )} + + +
+
+ + ); + } + + // 데이터 없음 (should not reach here) + return ( + + ); +} diff --git a/src/components/board/BoardManagement/index.tsx b/src/components/board/BoardManagement/index.tsx index a76596dc..24a031a8 100644 --- a/src/components/board/BoardManagement/index.tsx +++ b/src/components/board/BoardManagement/index.tsx @@ -1,5 +1,7 @@ 'use client'; +export { BoardDetailClientV2 } from './BoardDetailClientV2'; + import { useRouter } from 'next/navigation'; import { ClipboardList, Edit, Trash2, Plus } from 'lucide-react'; import { Badge } from '@/components/ui/badge'; diff --git a/src/components/business/construction/labor-management/LaborDetailClientV2.tsx b/src/components/business/construction/labor-management/LaborDetailClientV2.tsx new file mode 100644 index 00000000..816ab506 --- /dev/null +++ b/src/components/business/construction/labor-management/LaborDetailClientV2.tsx @@ -0,0 +1,122 @@ +/** + * LaborDetailClientV2 - IntegratedDetailTemplate 기반 노임 상세/등록/수정 + * + * 기존 LaborDetailClient를 IntegratedDetailTemplate으로 마이그레이션 + * - buttonPosition="top" 사용 (상단 버튼) + * - 6개 필드: 노임번호, 구분, 최소M, 최대M, 노임단가, 상태 + */ + +'use client'; + +import { useState, useEffect, useCallback } from 'react'; +import { useRouter } from 'next/navigation'; +import { toast } from 'sonner'; +import { + IntegratedDetailTemplate, + type DetailMode, +} from '@/components/templates/IntegratedDetailTemplate'; +import { laborDetailConfig } from './laborDetailConfig'; +import type { Labor, LaborFormData } from './types'; +import { getLabor, createLabor, updateLabor, deleteLabor } from './actions'; + +interface LaborDetailClientV2Props { + laborId?: string; + initialMode?: DetailMode; +} + +export default function LaborDetailClientV2({ + laborId, + initialMode = 'view', +}: LaborDetailClientV2Props) { + const router = useRouter(); + const [labor, setLabor] = useState(undefined); + const [isLoading, setIsLoading] = useState(false); + const [mode, setMode] = useState(initialMode); + + // 데이터 로드 + useEffect(() => { + if (laborId && initialMode !== 'create') { + const loadData = async () => { + setIsLoading(true); + try { + const result = await getLabor(laborId); + if (result.success && result.data) { + setLabor(result.data); + } else { + toast.error(result.error || '노임 정보를 불러오는데 실패했습니다.'); + router.push('/ko/construction/order/base-info/labor'); + } + } catch { + toast.error('노임 정보를 불러오는데 실패했습니다.'); + router.push('/ko/construction/order/base-info/labor'); + } finally { + setIsLoading(false); + } + }; + loadData(); + } + }, [laborId, initialMode, router]); + + // 저장 핸들러 + const handleSubmit = useCallback( + async (formData: Record) => { + try { + const submitData = laborDetailConfig.transformSubmitData!(formData) as unknown as LaborFormData; + + if (mode === 'create') { + const result = await createLabor(submitData); + if (result.success && result.data) { + return { success: true }; + } + return { success: false, error: result.error || '노임 등록에 실패했습니다.' }; + } else if (mode === 'edit' && laborId) { + const result = await updateLabor(laborId, submitData); + if (result.success) { + return { success: true }; + } + return { success: false, error: result.error || '노임 수정에 실패했습니다.' }; + } + + return { success: false, error: '알 수 없는 오류가 발생했습니다.' }; + } catch { + return { success: false, error: '저장 중 오류가 발생했습니다.' }; + } + }, + [mode, laborId] + ); + + // 삭제 핸들러 + const handleDelete = useCallback(async (id: string | number) => { + try { + const result = await deleteLabor(String(id)); + if (result.success) { + return { success: true }; + } + return { success: false, error: result.error || '노임 삭제에 실패했습니다.' }; + } catch { + return { success: false, error: '삭제 중 오류가 발생했습니다.' }; + } + }, []); + + // 모드 변경 핸들러 + const handleModeChange = useCallback((newMode: DetailMode) => { + setMode(newMode); + }, []); + + return ( + [0]['config']} + mode={mode} + initialData={labor as Record | undefined} + itemId={laborId} + isLoading={isLoading} + onSubmit={handleSubmit} + onDelete={handleDelete} + onModeChange={handleModeChange} + buttonPosition="top" + /> + ); +} + +// Named export for backwards compatibility +export { LaborDetailClientV2 }; diff --git a/src/components/business/construction/labor-management/index.tsx b/src/components/business/construction/labor-management/index.tsx index 65445755..9b616d16 100644 --- a/src/components/business/construction/labor-management/index.tsx +++ b/src/components/business/construction/labor-management/index.tsx @@ -1,5 +1,7 @@ export { default as LaborManagementClient } from './LaborManagementClient'; export { default as LaborDetailClient } from './LaborDetailClient'; +export { default as LaborDetailClientV2 } from './LaborDetailClientV2'; +export { laborDetailConfig } from './laborDetailConfig'; export * from './types'; export * from './constants'; export * from './actions'; diff --git a/src/components/business/construction/labor-management/laborDetailConfig.ts b/src/components/business/construction/labor-management/laborDetailConfig.ts new file mode 100644 index 00000000..660dd249 --- /dev/null +++ b/src/components/business/construction/labor-management/laborDetailConfig.ts @@ -0,0 +1,113 @@ +/** + * 노임관리 상세 페이지 설정 + * IntegratedDetailTemplate용 config + */ + +import { Hammer } from 'lucide-react'; +import type { DetailConfig, FieldDefinition } from '@/components/templates/IntegratedDetailTemplate'; +import type { LaborFormData, LaborCategory, LaborStatus } from './types'; +import { CATEGORY_OPTIONS, STATUS_OPTIONS } from './constants'; + +// Select 옵션 변환 ('all' 제외) +const categoryFieldOptions = CATEGORY_OPTIONS + .filter((o) => o.value !== 'all') + .map((o) => ({ value: o.value, label: o.label })); + +const statusFieldOptions = STATUS_OPTIONS + .filter((o) => o.value !== 'all') + .map((o) => ({ value: o.value, label: o.label })); + +// 필드 정의 +const fields: FieldDefinition[] = [ + { + key: 'laborNumber', + label: '노임번호', + type: 'text', + required: true, + placeholder: '노임번호를 입력하세요', + }, + { + key: 'category', + label: '구분', + type: 'select', + required: true, + options: categoryFieldOptions, + placeholder: '구분 선택', + }, + { + key: 'minM', + label: '최소 M', + type: 'number', + placeholder: '0.00', + helpText: '소수점 둘째자리까지 입력 가능', + }, + { + key: 'maxM', + label: '최대 M', + type: 'number', + placeholder: '0.00', + helpText: '소수점 둘째자리까지 입력 가능', + }, + { + key: 'laborPrice', + label: '노임단가', + type: 'number', + placeholder: '0', + formatValue: (value) => { + if (value === null || value === undefined || value === '') return '-'; + return Number(value).toLocaleString('ko-KR'); + }, + }, + { + key: 'status', + label: '상태', + type: 'select', + required: true, + options: statusFieldOptions, + placeholder: '상태 선택', + }, +]; + +// DetailConfig (Record 제약 때문에 타입 캐스팅 필요) +export const laborDetailConfig: DetailConfig = { + title: '노임', + description: '노임 정보를 등록하고 관리합니다.', + icon: Hammer, + basePath: '/construction/order/base-info/labor', + fields, + gridColumns: 2, + actions: { + showBack: true, + showDelete: true, + showEdit: true, + backLabel: '목록', + deleteLabel: '삭제', + editLabel: '수정', + submitLabel: undefined, // 모드에 따라 자동 결정 (등록/저장) + cancelLabel: '취소', + deleteConfirmMessage: { + title: '노임 삭제', + description: '이 노임을 삭제하시겠습니까? 이 작업은 취소할 수 없습니다.', + }, + }, + transformInitialData: (data: Record): Record => ({ + laborNumber: data.laborNumber, + category: data.category, + minM: data.minM, + maxM: data.maxM, + laborPrice: data.laborPrice, + status: data.status, + }), + transformSubmitData: (formData: Record): Partial => ({ + laborNumber: formData.laborNumber as string, + category: formData.category as LaborCategory, + minM: Number(formData.minM) || 0, + maxM: Number(formData.maxM) || 0, + laborPrice: formData.laborPrice === '' || formData.laborPrice === null + ? null + : Number(formData.laborPrice), + status: formData.status as LaborStatus, + }), +}; + +export default laborDetailConfig; diff --git a/src/components/business/construction/pricing-management/PricingDetailClientV2.tsx b/src/components/business/construction/pricing-management/PricingDetailClientV2.tsx new file mode 100644 index 00000000..7c0a310f --- /dev/null +++ b/src/components/business/construction/pricing-management/PricingDetailClientV2.tsx @@ -0,0 +1,136 @@ +/** + * PricingDetailClientV2 - IntegratedDetailTemplate 기반 단가 상세/등록/수정 + * + * 기존 PricingDetailClient를 IntegratedDetailTemplate으로 마이그레이션 + * - buttonPosition="top" 사용 (상단 버튼) + * - 12개 필드: 단가번호, 품목유형, 카테고리명, 품목명, 규격, 무게, 단위, 구분, 거래처, 판매단가, 상태, 비고 + * - 대부분 필드 readonly, 거래처/판매단가/상태/비고만 edit/create 모드에서 수정 가능 + */ + +'use client'; + +import { useState, useEffect, useCallback } from 'react'; +import { useRouter } from 'next/navigation'; +import { toast } from 'sonner'; +import { + IntegratedDetailTemplate, + type DetailMode, +} from '@/components/templates/IntegratedDetailTemplate'; +import { pricingDetailConfig } from './pricingDetailConfig'; +import type { Pricing, PricingFormData } from './types'; +import { getPricingDetail, createPricing, updatePricing, deletePricing } from './actions'; + +interface PricingDetailClientV2Props { + pricingId?: string; + initialMode?: DetailMode; +} + +export default function PricingDetailClientV2({ + pricingId, + initialMode = 'view', +}: PricingDetailClientV2Props) { + const router = useRouter(); + const [pricing, setPricing] = useState(undefined); + const [isLoading, setIsLoading] = useState(false); + const [mode, setMode] = useState(initialMode); + + // 데이터 로드 + useEffect(() => { + if (pricingId && initialMode !== 'create') { + const loadData = async () => { + setIsLoading(true); + try { + const result = await getPricingDetail(pricingId); + if (result.success && result.data) { + setPricing(result.data); + } else { + toast.error(result.error || '단가 정보를 불러오는데 실패했습니다.'); + router.push('/ko/construction/order/base-info/pricing'); + } + } catch { + toast.error('단가 정보를 불러오는데 실패했습니다.'); + router.push('/ko/construction/order/base-info/pricing'); + } finally { + setIsLoading(false); + } + }; + loadData(); + } + }, [pricingId, initialMode, router]); + + // 저장 핸들러 + const handleSubmit = useCallback( + async (formData: Record) => { + try { + const submitData = pricingDetailConfig.transformSubmitData!(formData) as unknown as PricingFormData; + + if (mode === 'create') { + const result = await createPricing(submitData); + if (result.success && result.data) { + return { success: true }; + } + return { success: false, error: result.error || '단가 등록에 실패했습니다.' }; + } else if (mode === 'edit' && pricingId) { + // edit 모드에서는 수정 가능한 필드만 전송 + const result = await updatePricing(pricingId, { + vendor: submitData.vendor, + sellingPrice: submitData.sellingPrice, + status: submitData.status, + }); + if (result.success) { + return { success: true }; + } + return { success: false, error: result.error || '단가 수정에 실패했습니다.' }; + } + + return { success: false, error: '알 수 없는 오류가 발생했습니다.' }; + } catch { + return { success: false, error: '저장 중 오류가 발생했습니다.' }; + } + }, + [mode, pricingId] + ); + + // 삭제 핸들러 + const handleDelete = useCallback(async (id: string | number) => { + try { + const result = await deletePricing(String(id)); + if (result.success) { + return { success: true }; + } + return { success: false, error: result.error || '단가 삭제에 실패했습니다.' }; + } catch { + return { success: false, error: '삭제 중 오류가 발생했습니다.' }; + } + }, []); + + // 모드 변경 핸들러 + const handleModeChange = useCallback( + (newMode: DetailMode) => { + if (newMode === 'edit' && pricingId) { + // edit 모드로 변경 시 별도 페이지로 이동 (기존 라우트 구조 유지) + router.push(`/ko/construction/order/base-info/pricing/${pricingId}/edit`); + } else { + setMode(newMode); + } + }, + [pricingId, router] + ); + + return ( + [0]['config']} + mode={mode} + initialData={pricing as Record | undefined} + itemId={pricingId} + isLoading={isLoading} + onSubmit={handleSubmit} + onDelete={handleDelete} + onModeChange={handleModeChange} + buttonPosition="top" + /> + ); +} + +// Named export for backwards compatibility +export { PricingDetailClientV2 }; diff --git a/src/components/business/construction/pricing-management/index.ts b/src/components/business/construction/pricing-management/index.ts index ac32f1aa..bc74ddfc 100644 --- a/src/components/business/construction/pricing-management/index.ts +++ b/src/components/business/construction/pricing-management/index.ts @@ -1,3 +1,6 @@ export { default as PricingListClient } from './PricingListClient'; +export { default as PricingDetailClient } from './PricingDetailClient'; +export { default as PricingDetailClientV2 } from './PricingDetailClientV2'; +export { pricingDetailConfig } from './pricingDetailConfig'; export * from './types'; export * from './actions'; diff --git a/src/components/business/construction/pricing-management/pricingDetailConfig.ts b/src/components/business/construction/pricing-management/pricingDetailConfig.ts new file mode 100644 index 00000000..a206ee17 --- /dev/null +++ b/src/components/business/construction/pricing-management/pricingDetailConfig.ts @@ -0,0 +1,174 @@ +/** + * 단가관리 상세 페이지 설정 + * IntegratedDetailTemplate용 config + */ + +import { DollarSign } from 'lucide-react'; +import type { DetailConfig, FieldDefinition, DetailMode } from '@/components/templates/IntegratedDetailTemplate'; +import type { PricingFormData, PricingStatus } from './types'; +import { STATUS_OPTIONS, PRICING_STATUS_LABELS } from './types'; +import { getVendorList } from './actions'; + +// 상태 옵션 (all 제외) +const statusFieldOptions = STATUS_OPTIONS + .filter((o) => o.value !== 'all') + .map((o) => ({ value: o.value, label: o.label })); + +// 필드 정의 +const fields: FieldDefinition[] = [ + { + key: 'pricingNumber', + label: '단가번호', + type: 'text', + disabled: true, // 항상 비활성화 (자동생성) + placeholder: '자동생성', + }, + { + key: 'itemType', + label: '품목유형', + type: 'text', + disabled: true, // 항상 비활성화 + }, + { + key: 'category', + label: '카테고리명', + type: 'text', + disabled: true, + }, + { + key: 'itemName', + label: '품목명', + type: 'text', + disabled: true, + }, + { + key: 'spec', + label: '규격', + type: 'text', + disabled: true, + }, + { + key: 'orderItemValue', + label: '무게', + type: 'text', + disabled: true, + helpText: '발주항목 (동적 컬럼)', + }, + { + key: 'unit', + label: '단위', + type: 'text', + disabled: true, + }, + { + key: 'division', + label: '구분', + type: 'text', + disabled: true, + }, + { + key: 'vendor', + label: '거래처', + type: 'select', + disabled: (mode: DetailMode) => mode === 'view', + fetchOptions: async () => { + const result = await getVendorList(); + if (result.success && result.data) { + return result.data.map((v) => ({ value: v.name, label: v.name })); + } + return []; + }, + placeholder: '거래처 선택', + }, + { + key: 'sellingPrice', + label: '판매단가', + type: 'number', + disabled: (mode: DetailMode) => mode === 'view', + placeholder: '판매단가 입력', + formatValue: (value) => { + if (value === null || value === undefined || value === '') return '-'; + return Number(value).toLocaleString('ko-KR'); + }, + }, + { + key: 'status', + label: '상태', + type: 'select', + disabled: (mode: DetailMode) => mode === 'view', + options: statusFieldOptions, + placeholder: '상태 선택', + formatValue: (value) => { + if (!value) return '-'; + return PRICING_STATUS_LABELS[value as PricingStatus] || String(value); + }, + }, + { + key: 'note', + label: '비고', + type: 'textarea', + disabled: (mode: DetailMode) => mode === 'view', + placeholder: '비고 입력', + gridSpan: 2, + }, +]; + +// DetailConfig +export const pricingDetailConfig: DetailConfig = { + title: '단가', + description: '단가 정보를 등록하고 관리합니다.', + icon: DollarSign, + basePath: '/construction/order/base-info/pricing', + fields, + gridColumns: 2, + actions: { + showBack: true, + showDelete: true, + showEdit: true, + backLabel: '목록', + deleteLabel: '삭제', + editLabel: '수정', + submitLabel: undefined, // 모드에 따라 자동 결정 + cancelLabel: '취소', + deleteConfirmMessage: { + title: '단가 삭제', + description: '이 단가를 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.', + }, + }, + transformInitialData: (data: Record): Record => { + // orderItems에서 첫 번째 항목 추출 + const orderItems = data.orderItems as Array<{ name?: string; value?: string }> | undefined; + const firstOrderItem = orderItems?.[0]; + + return { + pricingNumber: data.pricingNumber || '자동생성', + itemType: data.itemType, + category: data.category, + itemName: data.itemName, + spec: data.spec, + orderItemValue: firstOrderItem?.value || '-', + unit: data.unit, + division: data.division, + vendor: data.vendor, + sellingPrice: data.sellingPrice, + status: data.status || 'in_use', + note: '', + }; + }, + transformSubmitData: (formData: Record): Partial => ({ + itemType: formData.itemType as string, + category: formData.category as string, + itemName: formData.itemName as string, + spec: formData.spec as string, + orderItems: [], // 현재는 빈 배열 (기존 값 유지) + unit: formData.unit as string, + division: formData.division as string, + vendor: formData.vendor as string, + purchasePrice: 0, // 기존 값 유지 필요 + marginRate: 0, // 기존 값 유지 필요 + sellingPrice: Number(formData.sellingPrice) || 0, + status: formData.status as PricingStatus, + }), +}; + +export default pricingDetailConfig; diff --git a/src/components/business/construction/site-management/SiteDetailClientV2.tsx b/src/components/business/construction/site-management/SiteDetailClientV2.tsx new file mode 100644 index 00000000..2900354f --- /dev/null +++ b/src/components/business/construction/site-management/SiteDetailClientV2.tsx @@ -0,0 +1,140 @@ +'use client'; + +/** + * 현장관리 V2 클라이언트 컴포넌트 + * + * V2 라우팅 패턴: + * - /site-management/[id] → 조회 모드 (기본) + * - /site-management/[id]?mode=edit → 수정 모드 + * + * 기존 /site-management/[id]/edit → /site-management/[id]?mode=edit 으로 리다이렉트 + */ + +import { useState, useEffect } from 'react'; +import { useRouter, useSearchParams } from 'next/navigation'; +import SiteDetailForm from './SiteDetailForm'; +import type { Site } from './types'; +import { ContentLoadingSpinner } from '@/components/ui/loading-spinner'; +import { ErrorCard } from '@/components/ui/error-card'; + +type DetailMode = 'view' | 'edit'; + +interface SiteDetailClientV2Props { + siteId: string; + initialMode?: DetailMode; +} + +// 목업 데이터 (추후 API 연동 시 제거) +const MOCK_SITE: Site = { + id: '1', + siteCode: '123-12-12345', + partnerId: '1', + partnerName: '거래처명', + siteName: '현장명', + address: '', + status: 'active', + createdAt: '2025-09-01T00:00:00Z', + updatedAt: '2025-09-01T00:00:00Z', +}; + +const BASE_PATH = '/ko/construction/order/site-management'; + +export function SiteDetailClientV2({ siteId, initialMode }: SiteDetailClientV2Props) { + const router = useRouter(); + const searchParams = useSearchParams(); + + // URL의 mode 쿼리 파라미터 확인 + const modeFromQuery = searchParams.get('mode') as DetailMode | null; + + // 모드 결정: initialMode > query param > 기본값 'view' + const [mode, setMode] = useState(() => { + if (initialMode) return initialMode; + if (modeFromQuery === 'edit') return 'edit'; + return 'view'; + }); + + // 데이터 상태 + const [site, setSite] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + // 데이터 로드 + useEffect(() => { + async function fetchData() { + setIsLoading(true); + setError(null); + + try { + // TODO: API 연동 + // const result = await getSiteById(siteId); + // if (result.success && result.data) { + // setSite(result.data); + // } else { + // setError(result.error || '현장을 찾을 수 없습니다.'); + // } + + // 임시: 목업 데이터 사용 + await new Promise((resolve) => setTimeout(resolve, 300)); + + // ID가 숫자가 아니거나 너무 큰 경우 에러 + const numericId = parseInt(siteId, 10); + if (isNaN(numericId) || numericId > 100) { + setError('현장을 찾을 수 없습니다.'); + } else { + setSite({ ...MOCK_SITE, id: siteId }); + } + } catch { + setError('데이터를 불러오는 중 오류가 발생했습니다.'); + } finally { + setIsLoading(false); + } + } + + fetchData(); + }, [siteId]); + + // URL 쿼리 파라미터 변경 감지 + useEffect(() => { + if (modeFromQuery === 'edit') { + setMode('edit'); + } else if (!modeFromQuery && mode !== 'view') { + setMode('view'); + } + }, [modeFromQuery, mode]); + + // ===== 로딩 상태 ===== + if (isLoading) { + return ; + } + + // ===== 에러 상태 ===== + if (error) { + return ( + router.push(BASE_PATH)} + /> + ); + } + + // ===== 데이터 없음 ===== + if (!site) { + return ( + router.push(BASE_PATH)} + /> + ); + } + + // ===== 정상 렌더링 ===== + return ; +} + +export default SiteDetailClientV2; diff --git a/src/components/business/construction/site-management/SiteDetailForm.tsx b/src/components/business/construction/site-management/SiteDetailForm.tsx index 8d13413d..ac635f63 100644 --- a/src/components/business/construction/site-management/SiteDetailForm.tsx +++ b/src/components/business/construction/site-management/SiteDetailForm.tsx @@ -116,7 +116,7 @@ export default function SiteDetailForm({ site, mode = 'view' }: SiteDetailFormPr // 수정 버튼 클릭 const handleEditClick = useCallback(() => { if (site?.id) { - router.push(`/ko/construction/order/site-management/${site.id}/edit`); + router.push(`/ko/construction/order/site-management/${site.id}?mode=edit`); } }, [router, site?.id]); diff --git a/src/components/business/construction/structure-review/StructureReviewDetailClientV2.tsx b/src/components/business/construction/structure-review/StructureReviewDetailClientV2.tsx new file mode 100644 index 00000000..69bed7b7 --- /dev/null +++ b/src/components/business/construction/structure-review/StructureReviewDetailClientV2.tsx @@ -0,0 +1,148 @@ +'use client'; + +/** + * 구조검토 V2 클라이언트 컴포넌트 + * + * V2 라우팅 패턴: + * - /structure-review/[id] → 조회 모드 (기본) + * - /structure-review/[id]?mode=edit → 수정 모드 + * + * 기존 /structure-review/[id]/edit → /structure-review/[id]?mode=edit 으로 리다이렉트 + */ + +import { useState, useEffect } from 'react'; +import { useRouter, useSearchParams } from 'next/navigation'; +import StructureReviewDetailForm from './StructureReviewDetailForm'; +import type { StructureReview } from './types'; +import { ContentLoadingSpinner } from '@/components/ui/loading-spinner'; +import { ErrorCard } from '@/components/ui/error-card'; + +type DetailMode = 'view' | 'edit'; + +interface StructureReviewDetailClientV2Props { + reviewId: string; + initialMode?: DetailMode; +} + +// 목업 데이터 (추후 API 연동 시 제거) +const MOCK_REVIEW: StructureReview = { + id: '1', + reviewNumber: '123123', + partnerId: '1', + partnerName: '거래처명A', + siteId: '1', + siteName: '현장A', + requestDate: '2025-12-12', + reviewCompany: '회사명', + reviewerName: '홍길동', + reviewDate: '2025-12-15', + completionDate: null, + status: 'pending', + createdAt: '2025-12-01T00:00:00Z', + updatedAt: '2025-12-01T00:00:00Z', +}; + +const BASE_PATH = '/ko/construction/order/structure-review'; + +export function StructureReviewDetailClientV2({ + reviewId, + initialMode, +}: StructureReviewDetailClientV2Props) { + const router = useRouter(); + const searchParams = useSearchParams(); + + // URL의 mode 쿼리 파라미터 확인 + const modeFromQuery = searchParams.get('mode') as DetailMode | null; + + // 모드 결정: initialMode > query param > 기본값 'view' + const [mode, setMode] = useState(() => { + if (initialMode) return initialMode; + if (modeFromQuery === 'edit') return 'edit'; + return 'view'; + }); + + // 데이터 상태 + const [review, setReview] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + // 데이터 로드 + useEffect(() => { + async function fetchData() { + setIsLoading(true); + setError(null); + + try { + // TODO: API 연동 + // const result = await getStructureReviewById(reviewId); + // if (result.success && result.data) { + // setReview(result.data); + // } else { + // setError(result.error || '구조검토를 찾을 수 없습니다.'); + // } + + // 임시: 목업 데이터 사용 + await new Promise((resolve) => setTimeout(resolve, 300)); + + // ID가 숫자가 아니거나 너무 큰 경우 에러 + const numericId = parseInt(reviewId, 10); + if (isNaN(numericId) || numericId > 100) { + setError('구조검토를 찾을 수 없습니다.'); + } else { + setReview({ ...MOCK_REVIEW, id: reviewId }); + } + } catch { + setError('데이터를 불러오는 중 오류가 발생했습니다.'); + } finally { + setIsLoading(false); + } + } + + fetchData(); + }, [reviewId]); + + // URL 쿼리 파라미터 변경 감지 + useEffect(() => { + if (modeFromQuery === 'edit') { + setMode('edit'); + } else if (!modeFromQuery && mode !== 'view') { + setMode('view'); + } + }, [modeFromQuery, mode]); + + // ===== 로딩 상태 ===== + if (isLoading) { + return ; + } + + // ===== 에러 상태 ===== + if (error) { + return ( + router.push(BASE_PATH)} + /> + ); + } + + // ===== 데이터 없음 ===== + if (!review) { + return ( + router.push(BASE_PATH)} + /> + ); + } + + // ===== 정상 렌더링 ===== + return ; +} + +export default StructureReviewDetailClientV2; diff --git a/src/components/business/construction/structure-review/StructureReviewDetailForm.tsx b/src/components/business/construction/structure-review/StructureReviewDetailForm.tsx index 389829e8..a8e3ff35 100644 --- a/src/components/business/construction/structure-review/StructureReviewDetailForm.tsx +++ b/src/components/business/construction/structure-review/StructureReviewDetailForm.tsx @@ -125,7 +125,7 @@ export default function StructureReviewDetailForm({ // 수정 버튼 클릭 const handleEditClick = useCallback(() => { if (review?.id) { - router.push(`/ko/construction/order/structure-review/${review.id}/edit`); + router.push(`/ko/construction/order/structure-review/${review.id}?mode=edit`); } }, [router, review?.id]); diff --git a/src/components/clients/ClientDetailClientV2.tsx b/src/components/clients/ClientDetailClientV2.tsx new file mode 100644 index 00000000..312d74ae --- /dev/null +++ b/src/components/clients/ClientDetailClientV2.tsx @@ -0,0 +1,229 @@ +'use client'; + +/** + * 거래처(영업) 상세 클라이언트 컴포넌트 V2 + * IntegratedDetailTemplate 기반 마이그레이션 + * + * 클라이언트 사이드 데이터 페칭 (useClientList 훅 활용) + */ + +import { useState, useEffect, useCallback } from 'react'; +import { useRouter, useSearchParams } from 'next/navigation'; +import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate'; +import type { DetailMode } from '@/components/templates/IntegratedDetailTemplate/types'; +import type { Client, ClientFormData } from '@/hooks/useClientList'; +import { useClientList, transformClientToApiCreate, transformClientToApiUpdate } from '@/hooks/useClientList'; +import { clientDetailConfig } from './clientDetailConfig'; +import { toast } from 'sonner'; + +interface ClientDetailClientV2Props { + clientId?: string; + initialMode?: DetailMode; +} + +// 8자리 영문+숫자 코드 생성 +function generateCode(): string { + const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; + let result = ''; + for (let i = 0; i < 8; i++) { + result += chars.charAt(Math.floor(Math.random() * chars.length)); + } + return result; +} + +export function ClientDetailClientV2({ clientId, initialMode }: ClientDetailClientV2Props) { + const router = useRouter(); + const searchParams = useSearchParams(); + const { fetchClient, createClient, updateClient, deleteClient } = useClientList(); + + // URL 쿼리에서 모드 결정 + const modeFromQuery = searchParams.get('mode') as DetailMode | null; + const isNewMode = !clientId || clientId === 'new'; + + const [mode, setMode] = useState(() => { + if (isNewMode) return 'create'; + if (initialMode) return initialMode; + if (modeFromQuery === 'edit') return 'edit'; + return 'view'; + }); + + const [clientData, setClientData] = useState(null); + const [isLoading, setIsLoading] = useState(!isNewMode); + const [generatedCode, setGeneratedCode] = useState(''); + + // 데이터 로드 + useEffect(() => { + const loadData = async () => { + if (isNewMode) { + // 신규 등록 시 코드 생성 + const code = generateCode(); + setGeneratedCode(code); + setIsLoading(false); + return; + } + + setIsLoading(true); + try { + const data = await fetchClient(clientId!); + if (data) { + setClientData(data); + } else { + toast.error('거래처를 불러오는데 실패했습니다.'); + router.push(clientDetailConfig.basePath); + } + } catch (error) { + console.error('거래처 조회 실패:', error); + toast.error('거래처를 불러오는데 실패했습니다.'); + router.push(clientDetailConfig.basePath); + } finally { + setIsLoading(false); + } + }; + + loadData(); + }, [clientId, isNewMode, router, fetchClient]); + + // URL 쿼리 변경 감지 + useEffect(() => { + if (!isNewMode && modeFromQuery === 'edit') { + setMode('edit'); + } else if (!isNewMode && !modeFromQuery) { + setMode('view'); + } + }, [modeFromQuery, isNewMode]); + + // 모드 변경 핸들러 + const handleModeChange = useCallback( + (newMode: DetailMode) => { + setMode(newMode); + if (newMode === 'edit' && clientId) { + router.push(`${clientDetailConfig.basePath}/${clientId}?mode=edit`); + } else if (newMode === 'view' && clientId) { + router.push(`${clientDetailConfig.basePath}/${clientId}`); + } + }, + [router, clientId] + ); + + // 저장 핸들러 + const handleSubmit = useCallback( + async (formData: Record) => { + try { + // formData를 ClientFormData로 변환 + const clientFormData: ClientFormData = { + clientCode: (formData.clientCode as string) || generatedCode, + name: formData.name as string, + businessNo: formData.businessNo as string, + representative: formData.representative as string, + phone: formData.phone as string, + address: formData.address as string, + email: formData.email as string, + businessType: formData.businessType as string, + businessItem: formData.businessItem as string, + isActive: formData.isActive === 'true', + clientType: (formData.clientType as ClientFormData['clientType']) || '매입', + mobile: formData.mobile as string, + fax: formData.fax as string, + managerName: formData.managerName as string, + managerTel: formData.managerTel as string, + systemManager: formData.systemManager as string, + accountId: formData.accountId as string || '', + accountPassword: formData.accountPassword as string || '', + purchasePaymentDay: '말일', + salesPaymentDay: '말일', + taxAgreement: false, + taxAmount: '', + taxStartDate: '', + taxEndDate: '', + badDebt: false, + badDebtAmount: '', + badDebtReceiveDate: '', + badDebtEndDate: '', + badDebtProgress: '', + memo: formData.memo as string || '', + }; + + if (isNewMode) { + const result = await createClient(clientFormData); + if (result) { + toast.success('거래처가 등록되었습니다.'); + router.push(clientDetailConfig.basePath); + return { success: true }; + } + return { success: false, error: '거래처 등록에 실패했습니다.' }; + } else { + const result = await updateClient(clientId!, clientFormData); + if (result) { + toast.success('거래처가 수정되었습니다.'); + router.push(`${clientDetailConfig.basePath}/${clientId}`); + return { success: true }; + } + return { success: false, error: '거래처 수정에 실패했습니다.' }; + } + } catch (error) { + console.error('저장 실패:', error); + return { success: false, error: error instanceof Error ? error.message : '저장 중 오류가 발생했습니다.' }; + } + }, + [isNewMode, clientId, generatedCode, router, createClient, updateClient] + ); + + // 삭제 핸들러 + const handleDelete = useCallback( + async (id: string | number) => { + try { + const result = await deleteClient(String(id)); + if (result) { + toast.success('거래처가 삭제되었습니다.'); + router.push(clientDetailConfig.basePath); + return { success: true }; + } + return { success: false, error: '거래처 삭제에 실패했습니다.' }; + } catch (error) { + console.error('삭제 실패:', error); + return { success: false, error: error instanceof Error ? error.message : '삭제 중 오류가 발생했습니다.' }; + } + }, + [router, deleteClient] + ); + + // 취소 핸들러 + const handleCancel = useCallback(() => { + if (isNewMode) { + router.push(clientDetailConfig.basePath); + } else { + setMode('view'); + router.push(`${clientDetailConfig.basePath}/${clientId}`); + } + }, [router, clientId, isNewMode]); + + // 초기 데이터 (신규 등록 시 코드 포함) + const initialData = isNewMode + ? ({ code: generatedCode } as Client) + : clientData || undefined; + + // 타이틀 동적 설정 + const dynamicConfig = { + ...clientDetailConfig, + title: + mode === 'create' + ? '거래처' + : mode === 'edit' + ? clientData?.name || '거래처' + : clientData?.name || '거래처 상세', + }; + + return ( + + ); +} diff --git a/src/components/clients/actions.ts b/src/components/clients/actions.ts new file mode 100644 index 00000000..6ab9c172 --- /dev/null +++ b/src/components/clients/actions.ts @@ -0,0 +1,257 @@ +'use server'; + +/** + * 거래처(영업) 서버 액션 + * + * API Endpoints: + * - GET /api/v1/clients/{id} - 상세 조회 + * - POST /api/v1/clients - 등록 + * - PUT /api/v1/clients/{id} - 수정 + * - DELETE /api/v1/clients/{id} - 삭제 + * + * 🚨 401 에러 시 __authError: true 반환 → 클라이언트에서 로그인 페이지로 리다이렉트 + */ + +import { isNextRedirectError } from '@/lib/utils/redirect-error'; +import { serverFetch } from '@/lib/api/fetch-wrapper'; +import type { Client, ClientFormData, ClientApiResponse } from '@/hooks/useClientList'; +import { + transformClientFromApi, + transformClientToApiCreate, + transformClientToApiUpdate, +} from '@/hooks/useClientList'; + +// ===== 응답 타입 ===== +interface ActionResponse { + success: boolean; + data?: T; + error?: string; + __authError?: boolean; +} + +interface ApiResponse { + success: boolean; + data: T; + message?: string; +} + +// ===== 거래처 단건 조회 ===== +export async function getClientById(id: string): Promise> { + try { + const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/clients/${id}`; + console.log('[ClientActions] GET client URL:', url); + + const { response, error } = await serverFetch(url, { method: 'GET' }); + + // 🚨 401 인증 에러 + if (error?.__authError) { + console.error('[ClientActions] Auth error:', error); + return { success: false, __authError: true }; + } + + if (!response) { + console.error('[ClientActions] No response, error:', error); + return { + success: false, + error: error?.message || '서버 응답이 없습니다.', + }; + } + + // 응답 텍스트 먼저 읽기 + const responseText = await response.text(); + console.log('[ClientActions] Response status:', response.status); + console.log('[ClientActions] Response text:', responseText); + + if (!response.ok) { + console.error('[ClientActions] GET client error:', response.status); + return { + success: false, + error: `API 오류: ${response.status}`, + }; + } + + // JSON 파싱 + let result: ApiResponse; + try { + result = JSON.parse(responseText); + } catch { + console.error('[ClientActions] JSON parse error'); + return { + success: false, + error: 'JSON 파싱 오류', + }; + } + + if (!result.success || !result.data) { + console.error('[ClientActions] API returned error:', result); + return { + success: false, + error: result.message || '거래처를 찾을 수 없습니다.', + }; + } + + return { + success: true, + data: transformClientFromApi(result.data), + }; + } catch (error) { + if (isNextRedirectError(error)) throw error; + console.error('[ClientActions] getClientById error:', error); + return { + success: false, + error: error instanceof Error ? error.message : '거래처 조회 중 오류가 발생했습니다.', + }; + } +} + +// ===== 거래처 생성 ===== +export async function createClient( + formData: Partial +): Promise> { + try { + const apiData = transformClientToApiCreate(formData as ClientFormData); + const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/clients`; + + console.log('[ClientActions] POST client request:', apiData); + + const { response, error } = await serverFetch(url, { + method: 'POST', + body: JSON.stringify(apiData), + }); + + // 🚨 401 인증 에러 + if (error?.__authError) { + return { success: false, __authError: true }; + } + + if (!response) { + return { + success: false, + error: error?.message || '서버 오류가 발생했습니다.', + }; + } + + const result: ApiResponse = await response.json(); + console.log('[ClientActions] POST client response:', result); + + if (!response.ok || !result.success) { + return { + success: false, + error: result.message || '거래처 생성에 실패했습니다.', + }; + } + + return { + success: true, + data: transformClientFromApi(result.data), + }; + } catch (error) { + if (isNextRedirectError(error)) throw error; + console.error('[ClientActions] createClient error:', error); + return { + success: false, + error: error instanceof Error ? error.message : '거래처 생성 중 오류가 발생했습니다.', + }; + } +} + +// ===== 거래처 수정 ===== +export async function updateClient( + id: string, + formData: Partial +): Promise> { + try { + const apiData = transformClientToApiUpdate(formData as ClientFormData); + const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/clients/${id}`; + + console.log('[ClientActions] PUT client request:', apiData); + + const { response, error } = await serverFetch(url, { + method: 'PUT', + body: JSON.stringify(apiData), + }); + + // 🚨 401 인증 에러 + if (error?.__authError) { + return { success: false, __authError: true }; + } + + if (!response) { + return { + success: false, + error: error?.message || '서버 오류가 발생했습니다.', + }; + } + + const result: ApiResponse = await response.json(); + console.log('[ClientActions] PUT client response:', result); + + if (!response.ok || !result.success) { + return { + success: false, + error: result.message || '거래처 수정에 실패했습니다.', + }; + } + + return { + success: true, + data: transformClientFromApi(result.data), + }; + } catch (error) { + if (isNextRedirectError(error)) throw error; + console.error('[ClientActions] updateClient error:', error); + return { + success: false, + error: error instanceof Error ? error.message : '거래처 수정 중 오류가 발생했습니다.', + }; + } +} + +// ===== 거래처 삭제 ===== +export async function deleteClient(id: string): Promise { + try { + const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/clients/${id}`; + const { response, error } = await serverFetch(url, { method: 'DELETE' }); + + // 🚨 401 인증 에러 + if (error?.__authError) { + return { success: false, __authError: true }; + } + + if (!response) { + return { + success: false, + error: error?.message || '서버 오류가 발생했습니다.', + }; + } + + const result = await response.json(); + console.log('[ClientActions] DELETE client response:', result); + + if (!response.ok || !result.success) { + return { + success: false, + error: result.message || '거래처 삭제에 실패했습니다.', + }; + } + + return { success: true }; + } catch (error) { + if (isNextRedirectError(error)) throw error; + console.error('[ClientActions] deleteClient error:', error); + return { + success: false, + error: error instanceof Error ? error.message : '거래처 삭제 중 오류가 발생했습니다.', + }; + } +} + +// ===== 거래처 코드 생성 (8자리 영문+숫자) ===== +export async function generateClientCode(): Promise { + const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; + let result = ''; + for (let i = 0; i < 8; i++) { + result += chars.charAt(Math.floor(Math.random() * chars.length)); + } + return result; +} diff --git a/src/components/clients/clientDetailConfig.ts b/src/components/clients/clientDetailConfig.ts new file mode 100644 index 00000000..7598d472 --- /dev/null +++ b/src/components/clients/clientDetailConfig.ts @@ -0,0 +1,276 @@ +/** + * 거래처(영업) 상세 페이지 설정 + * IntegratedDetailTemplate V2 마이그레이션 + */ + +import { Building2 } from 'lucide-react'; +import type { DetailConfig, FieldDefinition, SectionDefinition } from '@/components/templates/IntegratedDetailTemplate/types'; +import type { ClientFormData, Client } from '@/hooks/useClientList'; + +// ===== 거래처 유형 옵션 ===== +const CLIENT_TYPE_OPTIONS = [ + { value: '매입', label: '매입' }, + { value: '매출', label: '매출' }, + { value: '매입매출', label: '매입매출' }, +]; + +// ===== 상태 옵션 ===== +const STATUS_OPTIONS = [ + { value: 'true', label: '활성' }, + { value: 'false', label: '비활성' }, +]; + +// ===== 필드 정의 ===== +export const clientFields: FieldDefinition[] = [ + // 기본 정보 + { + key: 'businessNo', + label: '사업자등록번호', + type: 'text', + required: true, + placeholder: '10자리 숫자 (예: 123-45-67890)', + validation: [ + { type: 'required', message: '사업자등록번호를 입력해주세요.' }, + { + type: 'custom', + message: '사업자등록번호는 10자리 숫자여야 합니다.', + validate: (value) => { + const digits = String(value || '').replace(/-/g, '').trim(); + return /^\d{10}$/.test(digits); + }, + }, + ], + }, + { + key: 'clientCode', + label: '거래처 코드', + type: 'text', + disabled: true, + helpText: '자동 생성됩니다', + }, + { + key: 'name', + label: '거래처명', + type: 'text', + required: true, + placeholder: '거래처명 입력', + validation: [ + { type: 'required', message: '거래처명을 입력해주세요.' }, + { type: 'minLength', value: 2, message: '거래처명은 2자 이상 입력해주세요.' }, + ], + }, + { + key: 'representative', + label: '대표자명', + type: 'text', + required: true, + placeholder: '대표자명 입력', + validation: [ + { type: 'required', message: '대표자명을 입력해주세요.' }, + { type: 'minLength', value: 2, message: '대표자명은 2자 이상 입력해주세요.' }, + ], + }, + { + key: 'clientType', + label: '거래처 유형', + type: 'radio', + required: true, + options: CLIENT_TYPE_OPTIONS, + }, + { + key: 'businessType', + label: '업태', + type: 'text', + placeholder: '제조업, 도소매업 등', + }, + { + key: 'businessItem', + label: '종목', + type: 'text', + placeholder: '철강, 건설 등', + }, + + // 연락처 정보 + { + key: 'address', + label: '주소', + type: 'text', + placeholder: '주소 입력', + gridSpan: 2, + }, + { + key: 'phone', + label: '전화번호', + type: 'tel', + placeholder: '02-1234-5678', + }, + { + key: 'mobile', + label: '모바일', + type: 'tel', + placeholder: '010-1234-5678', + }, + { + key: 'fax', + label: '팩스', + type: 'tel', + placeholder: '02-1234-5678', + }, + { + key: 'email', + label: '이메일', + type: 'email', + placeholder: 'example@company.com', + validation: [ + { + type: 'custom', + message: '올바른 이메일 형식이 아닙니다.', + validate: (value) => { + if (!value) return true; // 선택 필드 + return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(String(value)); + }, + }, + ], + }, + + // 담당자 정보 + { + key: 'managerName', + label: '담당자명', + type: 'text', + placeholder: '담당자명 입력', + }, + { + key: 'managerTel', + label: '담당자 전화', + type: 'tel', + placeholder: '010-1234-5678', + }, + { + key: 'systemManager', + label: '시스템 관리자', + type: 'text', + placeholder: '시스템 관리자명', + }, + + // 기타 정보 + { + key: 'memo', + label: '메모', + type: 'textarea', + placeholder: '메모 입력', + gridSpan: 2, + }, + { + key: 'isActive', + label: '상태', + type: 'radio', + options: STATUS_OPTIONS, + defaultValue: 'true', + }, +]; + +// ===== 섹션 정의 ===== +export const clientSections: SectionDefinition[] = [ + { + id: 'basicInfo', + title: '기본 정보', + description: '거래처의 기본 정보를 입력하세요', + fields: ['businessNo', 'clientCode', 'name', 'representative', 'clientType', 'businessType', 'businessItem'], + }, + { + id: 'contactInfo', + title: '연락처 정보', + description: '거래처의 연락처 정보를 입력하세요', + fields: ['address', 'phone', 'mobile', 'fax', 'email'], + }, + { + id: 'managerInfo', + title: '담당자 정보', + description: '거래처 담당자 정보를 입력하세요', + fields: ['managerName', 'managerTel', 'systemManager'], + }, + { + id: 'otherInfo', + title: '기타 정보', + description: '추가 정보를 입력하세요', + fields: ['memo', 'isActive'], + }, +]; + +// ===== 설정 ===== +export const clientDetailConfig: DetailConfig = { + title: '거래처', + description: '거래처 정보를 관리합니다', + icon: Building2, + basePath: '/ko/sales/client-management-sales-admin', + fields: clientFields, + sections: clientSections, + gridColumns: 2, + actions: { + submitLabel: '저장', + cancelLabel: '취소', + showDelete: true, + deleteLabel: '삭제', + showEdit: true, + editLabel: '수정', + showBack: true, + backLabel: '목록', + deleteConfirmMessage: { + title: '거래처 삭제', + description: '이 거래처를 삭제하시겠습니까? 삭제된 데이터는 복구할 수 없습니다.', + }, + }, + transformInitialData: (data: Client) => ({ + businessNo: data.businessNo || '', + clientCode: data.code || '', + name: data.name || '', + representative: data.representative || '', + clientType: data.clientType || '매입', + businessType: data.businessType || '', + businessItem: data.businessItem || '', + address: data.address || '', + phone: data.phone || '', + mobile: data.mobile || '', + fax: data.fax || '', + email: data.email || '', + managerName: data.managerName || '', + managerTel: data.managerTel || '', + systemManager: data.systemManager || '', + memo: data.memo || '', + isActive: data.status === '활성' ? 'true' : 'false', + }), + transformSubmitData: (formData): Partial => ({ + clientCode: formData.clientCode as string, + name: formData.name as string, + businessNo: formData.businessNo as string, + representative: formData.representative as string, + clientType: formData.clientType as ClientFormData['clientType'], + businessType: formData.businessType as string, + businessItem: formData.businessItem as string, + address: formData.address as string, + phone: formData.phone as string, + mobile: formData.mobile as string, + fax: formData.fax as string, + email: formData.email as string, + managerName: formData.managerName as string, + managerTel: formData.managerTel as string, + systemManager: formData.systemManager as string, + memo: formData.memo as string, + isActive: formData.isActive === 'true', + // 기본값 설정 + purchasePaymentDay: '말일', + salesPaymentDay: '말일', + taxAgreement: false, + taxAmount: '', + taxStartDate: '', + taxEndDate: '', + badDebt: false, + badDebtAmount: '', + badDebtReceiveDate: '', + badDebtEndDate: '', + badDebtProgress: '', + accountId: '', + accountPassword: '', + }), +}; diff --git a/src/components/customer-center/InquiryManagement/InquiryDetail.tsx b/src/components/customer-center/InquiryManagement/InquiryDetail.tsx index 6b761037..6fc62831 100644 --- a/src/components/customer-center/InquiryManagement/InquiryDetail.tsx +++ b/src/components/customer-center/InquiryManagement/InquiryDetail.tsx @@ -74,7 +74,7 @@ export function InquiryDetail({ }, [router]); const handleEdit = useCallback(() => { - router.push(`/ko/customer-center/qna/${inquiry.id}/edit`); + router.push(`/ko/customer-center/qna/${inquiry.id}?mode=edit`); }, [router, inquiry.id]); const handleConfirmDelete = useCallback(async () => { diff --git a/src/components/customer-center/InquiryManagement/InquiryDetailClientV2.tsx b/src/components/customer-center/InquiryManagement/InquiryDetailClientV2.tsx new file mode 100644 index 00000000..3133d331 --- /dev/null +++ b/src/components/customer-center/InquiryManagement/InquiryDetailClientV2.tsx @@ -0,0 +1,221 @@ +'use client'; + +/** + * 1:1 문의 V2 클라이언트 컴포넌트 + * + * V2 라우팅 패턴: + * - /qna/[id] → 조회 모드 (기본) + * - /qna/[id]?mode=edit → 수정 모드 + * - /qna/create → 등록 모드 + * + * 기존 /qna/[id]/edit → /qna/[id]?mode=edit 으로 리다이렉트 + */ + +import { useState, useEffect, useCallback } from 'react'; +import { useRouter, useSearchParams } from 'next/navigation'; +import { InquiryDetail } from './InquiryDetail'; +import { InquiryForm } from './InquiryForm'; +import { transformPostToInquiry, type Inquiry, type Comment } from './types'; +import { + getPost, + getComments, + createComment, + updateComment, + deleteComment, + deletePost, +} from '../shared/actions'; +import { transformApiToComment } from '../shared/types'; +import { ContentLoadingSpinner } from '@/components/ui/loading-spinner'; +import { ErrorCard } from '@/components/ui/error-card'; + +type DetailMode = 'view' | 'edit' | 'create'; + +interface InquiryDetailClientV2Props { + inquiryId?: string; + initialMode?: DetailMode; +} + +const BASE_PATH = '/ko/customer-center/qna'; + +export function InquiryDetailClientV2({ inquiryId, initialMode }: InquiryDetailClientV2Props) { + const router = useRouter(); + const searchParams = useSearchParams(); + + // URL의 mode 쿼리 파라미터 확인 + const modeFromQuery = searchParams.get('mode') as DetailMode | null; + const isCreateMode = !inquiryId || inquiryId === 'create'; + + // 모드 결정: create > initialMode > query param > 기본값 'view' + const [mode, setMode] = useState(() => { + if (isCreateMode) return 'create'; + if (initialMode) return initialMode; + if (modeFromQuery === 'edit') return 'edit'; + return 'view'; + }); + + // 데이터 상태 + const [inquiry, setInquiry] = useState(null); + const [comments, setComments] = useState([]); + const [isLoading, setIsLoading] = useState(!isCreateMode); + const [error, setError] = useState(null); + const [currentUserId, setCurrentUserId] = useState(''); + + // 현재 사용자 ID 가져오기 (localStorage에서) + useEffect(() => { + const userStr = localStorage.getItem('user'); + if (userStr) { + try { + const user = JSON.parse(userStr); + setCurrentUserId(String(user.id || '')); + } catch { + setCurrentUserId(''); + } + } + }, []); + + // 데이터 로드 + useEffect(() => { + if (isCreateMode) return; + + async function fetchData() { + setIsLoading(true); + setError(null); + + // 게시글과 댓글 동시 로드 + const [postResult, commentsResult] = await Promise.all([ + getPost('qna', inquiryId!), + getComments('qna', inquiryId!), + ]); + + if (postResult.success && postResult.data) { + setInquiry(transformPostToInquiry(postResult.data)); + } else { + setError(postResult.error || '문의를 찾을 수 없습니다.'); + } + + if (commentsResult.success && commentsResult.data) { + setComments(commentsResult.data.map(transformApiToComment)); + } + + setIsLoading(false); + } + + fetchData(); + }, [inquiryId, isCreateMode]); + + // URL 쿼리 파라미터 변경 감지 + useEffect(() => { + if (isCreateMode) return; + + if (modeFromQuery === 'edit') { + setMode('edit'); + } else if (!modeFromQuery && mode !== 'view') { + setMode('view'); + } + }, [modeFromQuery, isCreateMode, mode]); + + // ===== 댓글 핸들러 ===== + const handleAddComment = useCallback( + async (content: string) => { + if (!inquiryId) return; + const result = await createComment('qna', inquiryId, content); + if (result.success && result.data) { + setComments((prev) => [...prev, transformApiToComment(result.data!)]); + } else { + console.error('댓글 등록 실패:', result.error); + } + }, + [inquiryId] + ); + + const handleUpdateComment = useCallback( + async (commentId: string, content: string) => { + if (!inquiryId) return; + const result = await updateComment('qna', inquiryId, commentId, content); + if (result.success && result.data) { + setComments((prev) => + prev.map((c) => (c.id === commentId ? transformApiToComment(result.data!) : c)) + ); + } else { + console.error('댓글 수정 실패:', result.error); + } + }, + [inquiryId] + ); + + const handleDeleteComment = useCallback( + async (commentId: string) => { + if (!inquiryId) return; + const result = await deleteComment('qna', inquiryId, commentId); + if (result.success) { + setComments((prev) => prev.filter((c) => c.id !== commentId)); + } else { + console.error('댓글 삭제 실패:', result.error); + } + }, + [inquiryId] + ); + + // 문의 삭제 + const handleDeleteInquiry = useCallback(async () => { + if (!inquiryId) return false; + const result = await deletePost('qna', inquiryId); + return result.success; + }, [inquiryId]); + + // ===== 로딩 상태 ===== + if (isLoading) { + return ; + } + + // ===== 에러 상태 ===== + if (error) { + return ( + router.push(BASE_PATH)} + /> + ); + } + + // ===== 등록 모드 ===== + if (mode === 'create') { + return ; + } + + // ===== 수정 모드 ===== + if (mode === 'edit' && inquiry) { + return ; + } + + // ===== 조회 모드 ===== + if (inquiry) { + return ( + + ); + } + + // 데이터 없음 (비정상 상태) + return ( + router.push(BASE_PATH)} + /> + ); +} + +export default InquiryDetailClientV2; diff --git a/src/components/customer-center/InquiryManagement/index.tsx b/src/components/customer-center/InquiryManagement/index.tsx index 4fe31746..26082215 100644 --- a/src/components/customer-center/InquiryManagement/index.tsx +++ b/src/components/customer-center/InquiryManagement/index.tsx @@ -1,4 +1,5 @@ export { InquiryList } from './InquiryList'; export { InquiryDetail } from './InquiryDetail'; export { InquiryForm } from './InquiryForm'; +export { InquiryDetailClientV2 } from './InquiryDetailClientV2'; export * from './types'; \ No newline at end of file diff --git a/src/components/hr/AttendanceManagement/AttendanceInfoDialog.tsx b/src/components/hr/AttendanceManagement/AttendanceInfoDialog.tsx index 2a0e5d8c..412d9202 100644 --- a/src/components/hr/AttendanceManagement/AttendanceInfoDialog.tsx +++ b/src/components/hr/AttendanceManagement/AttendanceInfoDialog.tsx @@ -127,7 +127,7 @@ export function AttendanceInfoDialog({ onValueChange={(value) => handleChange('employeeId', value)} > - + {employees.map((employee) => ( diff --git a/src/components/hr/AttendanceManagement/ReasonInfoDialog.tsx b/src/components/hr/AttendanceManagement/ReasonInfoDialog.tsx index 73e4181b..b67de2a8 100644 --- a/src/components/hr/AttendanceManagement/ReasonInfoDialog.tsx +++ b/src/components/hr/AttendanceManagement/ReasonInfoDialog.tsx @@ -92,7 +92,7 @@ export function ReasonInfoDialog({ onValueChange={(value) => handleChange('employeeId', value)} > - + {employees.map((employee) => ( @@ -140,7 +140,7 @@ export function ReasonInfoDialog({ onValueChange={(value) => handleChange('reasonType', value)} > - + {Object.entries(REASON_TYPE_LABELS).map(([value, label]) => ( diff --git a/src/components/process-management/ProcessDetailClientV2.tsx b/src/components/process-management/ProcessDetailClientV2.tsx new file mode 100644 index 00000000..a6ce3354 --- /dev/null +++ b/src/components/process-management/ProcessDetailClientV2.tsx @@ -0,0 +1,136 @@ +'use client'; + +/** + * 공정관리 상세 클라이언트 컴포넌트 V2 + * + * 라우팅 구조 변경: /[id], /[id]/edit, /new → /[id]?mode=view|edit, /new + * 기존 ProcessDetail, ProcessForm 컴포넌트 활용 + */ + +import { useState, useEffect, useCallback } from 'react'; +import { useRouter, useSearchParams } from 'next/navigation'; +import { ProcessDetail } from './ProcessDetail'; +import { ProcessForm } from './ProcessForm'; +import { getProcessById } from './actions'; +import type { Process } from '@/types/process'; +import { ContentLoadingSpinner } from '@/components/ui/loading-spinner'; +import { ErrorCard } from '@/components/ui/error-card'; +import { toast } from 'sonner'; + +type DetailMode = 'view' | 'edit' | 'create'; + +interface ProcessDetailClientV2Props { + processId?: string; + initialMode?: DetailMode; +} + +const BASE_PATH = '/ko/master-data/process-management'; + +export function ProcessDetailClientV2({ processId, initialMode }: ProcessDetailClientV2Props) { + const router = useRouter(); + const searchParams = useSearchParams(); + + // URL 쿼리에서 모드 결정 + const modeFromQuery = searchParams.get('mode') as DetailMode | null; + const isNewMode = !processId || processId === 'new'; + + const [mode, setMode] = useState(() => { + if (isNewMode) return 'create'; + if (initialMode) return initialMode; + if (modeFromQuery === 'edit') return 'edit'; + return 'view'; + }); + + const [processData, setProcessData] = useState(null); + const [isLoading, setIsLoading] = useState(!isNewMode); + const [error, setError] = useState(null); + + // 데이터 로드 + useEffect(() => { + const loadData = async () => { + if (isNewMode) { + setIsLoading(false); + return; + } + + setIsLoading(true); + setError(null); + + try { + const result = await getProcessById(processId!); + if (result.success && result.data) { + setProcessData(result.data); + } else { + setError(result.error || '공정 정보를 찾을 수 없습니다.'); + toast.error('공정을 불러오는데 실패했습니다.'); + } + } catch (err) { + console.error('공정 조회 실패:', err); + setError('공정 정보를 불러오는 중 오류가 발생했습니다.'); + toast.error('공정을 불러오는데 실패했습니다.'); + } finally { + setIsLoading(false); + } + }; + + loadData(); + }, [processId, isNewMode]); + + // URL 쿼리 변경 감지 + useEffect(() => { + if (!isNewMode && modeFromQuery === 'edit') { + setMode('edit'); + } else if (!isNewMode && !modeFromQuery) { + setMode('view'); + } + }, [modeFromQuery, isNewMode]); + + // 로딩 중 + if (isLoading) { + return ; + } + + // 에러 발생 (view/edit 모드에서) + if (error && !isNewMode) { + return ( + + ); + } + + // 등록 모드 + if (mode === 'create') { + return ; + } + + // 수정 모드 + if (mode === 'edit' && processData) { + return ; + } + + // 상세 보기 모드 + if (mode === 'view' && processData) { + return ; + } + + // 데이터 없음 (should not reach here) + return ( + + ); +} diff --git a/src/components/process-management/index.ts b/src/components/process-management/index.ts index 505f73f0..7ea3c95f 100644 --- a/src/components/process-management/index.ts +++ b/src/components/process-management/index.ts @@ -1,5 +1,6 @@ export { default as ProcessListClient } from './ProcessListClient'; export { ProcessForm } from './ProcessForm'; export { ProcessDetail } from './ProcessDetail'; +export { ProcessDetailClientV2 } from './ProcessDetailClientV2'; export { RuleModal } from './RuleModal'; export { ProcessWorkLogPreviewModal } from './ProcessWorkLogPreviewModal'; \ No newline at end of file diff --git a/src/components/settings/PopupManagement/PopupDetailClientV2.tsx b/src/components/settings/PopupManagement/PopupDetailClientV2.tsx new file mode 100644 index 00000000..f3fc8806 --- /dev/null +++ b/src/components/settings/PopupManagement/PopupDetailClientV2.tsx @@ -0,0 +1,201 @@ +'use client'; + +/** + * 팝업관리 상세 클라이언트 컴포넌트 V2 + * IntegratedDetailTemplate 기반 마이그레이션 + */ + +import { useState, useEffect, useCallback } from 'react'; +import { useRouter, useSearchParams } from 'next/navigation'; +import { format } from 'date-fns'; +import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate'; +import type { DetailMode } from '@/components/templates/IntegratedDetailTemplate/types'; +import type { Popup, PopupFormData } from './types'; +import { getPopupById, createPopup, updatePopup, deletePopup } from './actions'; +import { popupDetailConfig } from './popupDetailConfig'; +import { toast } from 'sonner'; + +interface PopupDetailClientV2Props { + popupId?: string; + initialMode?: DetailMode; +} + +// 현재 로그인 사용자 정보 (실제로는 auth context에서 가져옴) +const CURRENT_USER = { + id: 'user1', + name: '홍길동', +}; + +export function PopupDetailClientV2({ popupId, initialMode }: PopupDetailClientV2Props) { + const router = useRouter(); + const searchParams = useSearchParams(); + + // URL 쿼리에서 모드 결정 + const modeFromQuery = searchParams.get('mode') as DetailMode | null; + const isNewMode = !popupId || popupId === 'new'; + + const [mode, setMode] = useState(() => { + if (isNewMode) return 'create'; + if (initialMode) return initialMode; + if (modeFromQuery === 'edit') return 'edit'; + return 'view'; + }); + + const [popupData, setPopupData] = useState(null); + const [isLoading, setIsLoading] = useState(!isNewMode); + + // 데이터 로드 + useEffect(() => { + const loadData = async () => { + if (isNewMode) { + setIsLoading(false); + return; + } + + setIsLoading(true); + try { + const data = await getPopupById(popupId!); + if (data) { + setPopupData(data); + } else { + toast.error('팝업을 불러오는데 실패했습니다.'); + router.push(popupDetailConfig.basePath); + } + } catch (error) { + console.error('팝업 조회 실패:', error); + toast.error('팝업을 불러오는데 실패했습니다.'); + router.push(popupDetailConfig.basePath); + } finally { + setIsLoading(false); + } + }; + + loadData(); + }, [popupId, isNewMode, router]); + + // URL 쿼리 변경 감지 + useEffect(() => { + if (!isNewMode && modeFromQuery === 'edit') { + setMode('edit'); + } else if (!isNewMode && !modeFromQuery) { + setMode('view'); + } + }, [modeFromQuery, isNewMode]); + + // 모드 변경 핸들러 + const handleModeChange = useCallback( + (newMode: DetailMode) => { + setMode(newMode); + if (newMode === 'edit' && popupId) { + router.push(`${popupDetailConfig.basePath}/${popupId}?mode=edit`); + } else if (newMode === 'view' && popupId) { + router.push(`${popupDetailConfig.basePath}/${popupId}`); + } + }, + [router, popupId] + ); + + // 저장 핸들러 + const handleSubmit = useCallback( + async (formData: Record) => { + try { + const popupFormData: PopupFormData = { + target: (formData.target as PopupFormData['target']) || 'all', + title: formData.title as string, + content: formData.content as string, + status: (formData.status as PopupFormData['status']) || 'inactive', + startDate: formData.startDate as string, + endDate: formData.endDate as string, + }; + + if (isNewMode) { + const result = await createPopup(popupFormData); + if (result.success) { + toast.success('팝업이 등록되었습니다.'); + router.push(popupDetailConfig.basePath); + return { success: true }; + } + return { success: false, error: result.error || '팝업 등록에 실패했습니다.' }; + } else { + const result = await updatePopup(popupId!, popupFormData); + if (result.success) { + toast.success('팝업이 수정되었습니다.'); + router.push(`${popupDetailConfig.basePath}/${popupId}`); + return { success: true }; + } + return { success: false, error: result.error || '팝업 수정에 실패했습니다.' }; + } + } catch (error) { + console.error('저장 실패:', error); + return { success: false, error: error instanceof Error ? error.message : '저장 중 오류가 발생했습니다.' }; + } + }, + [isNewMode, popupId, router] + ); + + // 삭제 핸들러 + const handleDelete = useCallback( + async (id: string | number) => { + try { + const result = await deletePopup(String(id)); + if (result.success) { + toast.success('팝업이 삭제되었습니다.'); + router.push(popupDetailConfig.basePath); + return { success: true }; + } + return { success: false, error: result.error || '팝업 삭제에 실패했습니다.' }; + } catch (error) { + console.error('삭제 실패:', error); + return { success: false, error: error instanceof Error ? error.message : '삭제 중 오류가 발생했습니다.' }; + } + }, + [router] + ); + + // 취소 핸들러 + const handleCancel = useCallback(() => { + if (isNewMode) { + router.push(popupDetailConfig.basePath); + } else { + setMode('view'); + router.push(`${popupDetailConfig.basePath}/${popupId}`); + } + }, [router, popupId, isNewMode]); + + // 초기 데이터 (신규 등록 시 기본값 포함) + const initialData = isNewMode + ? ({ + target: 'all', + status: 'inactive', + author: CURRENT_USER.name, + createdAt: format(new Date(), 'yyyy-MM-dd HH:mm'), + startDate: format(new Date(), 'yyyy-MM-dd'), + endDate: format(new Date(), 'yyyy-MM-dd'), + } as unknown as Popup) + : popupData || undefined; + + // 타이틀 동적 설정 + const dynamicConfig = { + ...popupDetailConfig, + title: + mode === 'create' + ? '팝업관리' + : mode === 'edit' + ? popupData?.title || '팝업관리' + : popupData?.title || '팝업관리 상세', + }; + + return ( + + ); +} diff --git a/src/components/settings/PopupManagement/index.tsx b/src/components/settings/PopupManagement/index.tsx index cd2c09e8..e1529d91 100644 --- a/src/components/settings/PopupManagement/index.tsx +++ b/src/components/settings/PopupManagement/index.tsx @@ -1,4 +1,5 @@ export { PopupList } from './PopupList'; export { PopupForm } from './PopupForm'; export { PopupDetail } from './PopupDetail'; +export { PopupDetailClientV2 } from './PopupDetailClientV2'; export * from './types'; \ No newline at end of file diff --git a/src/components/settings/PopupManagement/popupDetailConfig.ts b/src/components/settings/PopupManagement/popupDetailConfig.ts new file mode 100644 index 00000000..3719d4ed --- /dev/null +++ b/src/components/settings/PopupManagement/popupDetailConfig.ts @@ -0,0 +1,191 @@ +/** + * 팝업관리 상세 페이지 설정 + * IntegratedDetailTemplate V2 마이그레이션 + */ + +import { Megaphone } from 'lucide-react'; +import type { DetailConfig, FieldDefinition, SectionDefinition } from '@/components/templates/IntegratedDetailTemplate/types'; +import type { Popup, PopupFormData, PopupTarget, PopupStatus } from './types'; +import { RichTextEditor } from '@/components/board/RichTextEditor'; +import { createElement } from 'react'; + +// ===== 대상 옵션 ===== +const TARGET_OPTIONS = [ + { value: 'all', label: '전사' }, + { value: 'department', label: '부서별' }, +]; + +// ===== 상태 옵션 ===== +const STATUS_OPTIONS = [ + { value: 'inactive', label: '사용안함' }, + { value: 'active', label: '사용함' }, +]; + +// ===== 필드 정의 ===== +export const popupFields: FieldDefinition[] = [ + { + key: 'target', + label: '대상', + type: 'select', + required: true, + options: TARGET_OPTIONS, + placeholder: '대상을 선택해주세요', + validation: [ + { type: 'required', message: '대상을 선택해주세요.' }, + ], + }, + { + key: 'startDate', + label: '시작일', + type: 'date', + required: true, + validation: [ + { type: 'required', message: '시작일을 선택해주세요.' }, + ], + }, + { + key: 'endDate', + label: '종료일', + type: 'date', + required: true, + validation: [ + { type: 'required', message: '종료일을 선택해주세요.' }, + { + type: 'custom', + message: '종료일은 시작일 이후여야 합니다.', + validate: (value, formData) => { + const startDate = formData.startDate as string; + const endDate = value as string; + if (!startDate || !endDate) return true; + return endDate >= startDate; + }, + }, + ], + }, + { + key: 'title', + label: '제목', + type: 'text', + required: true, + placeholder: '제목을 입력해주세요', + gridSpan: 2, + validation: [ + { type: 'required', message: '제목을 입력해주세요.' }, + ], + }, + { + key: 'content', + label: '내용', + type: 'custom', + required: true, + gridSpan: 2, + validation: [ + { + type: 'custom', + message: '내용을 입력해주세요.', + validate: (value) => { + const content = value as string; + return !!content && content.trim() !== '' && content !== '

'; + }, + }, + ], + renderField: ({ value, onChange, mode, disabled }) => { + if (mode === 'view') { + // View 모드: HTML 렌더링 + return createElement('div', { + className: 'border border-gray-200 rounded-md p-4 bg-gray-50 min-h-[100px] prose prose-sm max-w-none', + dangerouslySetInnerHTML: { __html: (value as string) || '' }, + }); + } + // Edit/Create 모드: RichTextEditor + return createElement(RichTextEditor, { + value: (value as string) || '', + onChange: onChange, + placeholder: '내용을 입력해주세요', + minHeight: '200px', + disabled: disabled, + }); + }, + formatValue: (value) => { + if (!value) return '-'; + return createElement('div', { + className: 'border border-gray-200 rounded-md p-4 bg-gray-50 min-h-[100px] prose prose-sm max-w-none', + dangerouslySetInnerHTML: { __html: value as string }, + }); + }, + }, + { + key: 'status', + label: '상태', + type: 'radio', + options: STATUS_OPTIONS, + defaultValue: 'inactive', + }, + { + key: 'author', + label: '작성자', + type: 'text', + disabled: true, + hideInForm: false, + }, + { + key: 'createdAt', + label: '등록일시', + type: 'text', + disabled: true, + hideInForm: false, + }, +]; + +// ===== 섹션 정의 ===== +export const popupSections: SectionDefinition[] = [ + { + id: 'basicInfo', + title: '팝업 정보', + description: '팝업의 기본 정보를 입력해주세요', + fields: ['target', 'startDate', 'endDate', 'title', 'content', 'status', 'author', 'createdAt'], + }, +]; + +// ===== 설정 ===== +export const popupDetailConfig: DetailConfig = { + title: '팝업관리', + description: '팝업 목록을 관리합니다', + icon: Megaphone, + basePath: '/ko/settings/popup-management', + fields: popupFields, + sections: popupSections, + gridColumns: 2, + actions: { + submitLabel: '저장', + cancelLabel: '취소', + showDelete: true, + deleteLabel: '삭제', + showEdit: true, + editLabel: '수정', + showBack: true, + backLabel: '목록', + deleteConfirmMessage: { + title: '팝업 삭제', + description: '이 팝업을 삭제하시겠습니까? 삭제된 데이터는 복구할 수 없습니다.', + }, + }, + transformInitialData: (data: Popup) => ({ + target: data.target || 'all', + startDate: data.startDate || '', + endDate: data.endDate || '', + title: data.title || '', + content: data.content || '', + status: data.status || 'inactive', + author: data.author || '', + createdAt: data.createdAt || '', + }), + transformSubmitData: (formData): Partial => ({ + target: formData.target as PopupTarget, + title: formData.title as string, + content: formData.content as string, + status: formData.status as PopupStatus, + startDate: formData.startDate as string, + endDate: formData.endDate as string, + }), +}; diff --git a/src/components/templates/IntegratedDetailTemplate/FieldInput.tsx b/src/components/templates/IntegratedDetailTemplate/FieldInput.tsx new file mode 100644 index 00000000..b52fc3ec --- /dev/null +++ b/src/components/templates/IntegratedDetailTemplate/FieldInput.tsx @@ -0,0 +1,283 @@ +'use client'; + +/** + * FieldInput - 순수 입력 컴포넌트 렌더러 + * + * 라벨, 에러 메시지 없이 순수 입력 컴포넌트만 반환 + * 레이아웃(라벨, 에러, description)은 DetailField가 담당 + */ + +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Textarea } from '@/components/ui/textarea'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'; +import { Checkbox } from '@/components/ui/checkbox'; +import { cn } from '@/lib/utils'; +import type { FieldDefinition, DetailMode, FieldOption } from './types'; +import type { ReactNode } from 'react'; + +export interface FieldInputProps { + field: FieldDefinition; + value: unknown; + onChange: (value: unknown) => void; + mode: DetailMode; + error?: string; + dynamicOptions?: FieldOption[]; +} + +export function FieldInput({ + field, + value, + onChange, + mode, + error, + dynamicOptions, +}: FieldInputProps) { + const isViewMode = mode === 'view'; + const isDisabled = + field.readonly || + (typeof field.disabled === 'function' + ? field.disabled(mode) + : field.disabled); + + // 옵션 (동적 로드된 옵션 우선) + const options = dynamicOptions || field.options || []; + + // View 모드: 값만 표시 (라벨 없이) + if (isViewMode) { + return ( +
+ {renderViewValue(field, value, options)} +
+ ); + } + + // Form 모드: 입력 필드만 반환 (라벨, 에러 없이) + return renderFormField(field, value, onChange, isDisabled, options, error); +} + +// View 모드 값 렌더링 +function renderViewValue( + field: FieldDefinition, + value: unknown, + options: FieldOption[] +): ReactNode { + // 커스텀 포맷터가 있으면 사용 + if (field.formatValue) { + return field.formatValue(value); + } + + // 값이 없으면 '-' 표시 + if (value === null || value === undefined || value === '') { + return '-'; + } + + switch (field.type) { + case 'password': + return '****'; + + case 'select': + case 'radio': { + const option = options.find((opt) => opt.value === value); + return option?.label || String(value); + } + + case 'checkbox': + return value ? '예' : '아니오'; + + case 'date': + if (typeof value === 'string') { + try { + return new Date(value).toLocaleDateString('ko-KR'); + } catch { + return value; + } + } + return String(value); + + case 'textarea': + return ( +
{String(value)}
+ ); + + default: + return String(value); + } +} + +// Form 모드 필드 렌더링 (입력 컴포넌트만) +function renderFormField( + field: FieldDefinition, + value: unknown, + onChange: (value: unknown) => void, + disabled: boolean, + options: FieldOption[], + error?: string +): ReactNode { + const stringValue = value !== null && value !== undefined ? String(value) : ''; + const hasError = !!error; + + switch (field.type) { + case 'text': + case 'email': + case 'tel': + return ( + onChange(e.target.value)} + placeholder={field.placeholder} + disabled={disabled} + className={cn(hasError && 'border-destructive')} + /> + ); + + case 'number': + return ( + onChange(e.target.value ? Number(e.target.value) : '')} + placeholder={field.placeholder} + disabled={disabled} + className={cn(hasError && 'border-destructive')} + /> + ); + + case 'password': + return ( + onChange(e.target.value)} + placeholder={field.placeholder || '****'} + disabled={disabled} + className={cn(hasError && 'border-destructive')} + /> + ); + + case 'textarea': + return ( +