Merge branch 'feature/universal-detail-component' into master
Phase 6 IntegratedDetailTemplate 마이그레이션 완료 - 41개 컴포넌트 마이그레이션 - 34개 Config 파일 생성 - 코드 1112줄 감소
This commit is contained in:
213
claudedocs/[ANALYSIS-2026-01-20] 공통화-현황-분석.md
Normal file
213
claudedocs/[ANALYSIS-2026-01-20] 공통화-현황-분석.md
Normal file
@@ -0,0 +1,213 @@
|
||||
# 프로젝트 공통화 현황 분석
|
||||
|
||||
## 1. 핵심 지표 요약
|
||||
|
||||
| 구분 | 적용 현황 | 비고 |
|
||||
|------|----------|------|
|
||||
| **IntegratedDetailTemplate** | 96개 파일 (228회 사용) | 상세/수정/등록 페이지 통합 |
|
||||
| **IntegratedListTemplateV2** | 50개 파일 (60회 사용) | 목록 페이지 통합 |
|
||||
| **DetailConfig 파일** | 39개 생성 | 설정 기반 페이지 구성 |
|
||||
| **레거시 패턴 (PageLayout 직접 사용)** | ~40-50개 파일 | 마이그레이션 대상 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 공통화 달성률
|
||||
|
||||
### 2.1 상세 페이지 (Detail)
|
||||
```
|
||||
총 Detail 컴포넌트: ~105개
|
||||
IntegratedDetailTemplate 적용: ~65개
|
||||
적용률: 약 62%
|
||||
```
|
||||
|
||||
### 2.2 목록 페이지 (List)
|
||||
```
|
||||
총 List 컴포넌트: ~61개
|
||||
IntegratedListTemplateV2 적용: ~50개
|
||||
적용률: 약 82%
|
||||
```
|
||||
|
||||
### 2.3 폼 컴포넌트 (Form)
|
||||
```
|
||||
총 Form 컴포넌트: ~72개
|
||||
공통 템플릿 미적용 (개별 구현)
|
||||
적용률: 0%
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 잘 공통화된 영역 ✅
|
||||
|
||||
### 3.1 템플릿 시스템
|
||||
| 템플릿 | 용도 | 적용 현황 |
|
||||
|--------|------|----------|
|
||||
| IntegratedDetailTemplate | 상세/수정/등록 | 96개 파일 |
|
||||
| IntegratedListTemplateV2 | 목록 페이지 | 50개 파일 |
|
||||
| UniversalListPage | 범용 목록 | 7개 파일 |
|
||||
|
||||
### 3.2 UI 컴포넌트 (Radix UI 기반)
|
||||
- **AlertDialog**: 65개 파일에서 일관되게 사용
|
||||
- **Dialog**: 142개 파일에서 사용
|
||||
- **Toast (Sonner)**: 133개 파일에서 일관되게 사용
|
||||
- **Pagination**: 54개 파일에서 통합 사용
|
||||
|
||||
### 3.3 데이터 테이블
|
||||
- **DataTable**: 공통 컴포넌트로 추상화됨
|
||||
- **IntegratedListTemplateV2에 통합**: 자동 페이지네이션, 필터링
|
||||
|
||||
---
|
||||
|
||||
## 4. 추가 공통화 기회 🔧
|
||||
|
||||
### 4.1 우선순위 높음 (High Priority)
|
||||
|
||||
#### 📋 Form 템플릿 (IntegratedFormTemplate)
|
||||
**현황**: 72개 Form 컴포넌트가 개별적으로 구현됨
|
||||
**제안**:
|
||||
```typescript
|
||||
// 제안: IntegratedFormTemplate
|
||||
<IntegratedFormTemplate
|
||||
config={formConfig}
|
||||
mode="create" | "edit"
|
||||
initialData={data}
|
||||
onSubmit={handleSubmit}
|
||||
onCancel={handleCancel}
|
||||
renderFields={() => <CustomFields />}
|
||||
/>
|
||||
```
|
||||
**효과**:
|
||||
- 폼 레이아웃 일관성
|
||||
- 버튼 영역 통합 (저장/취소/삭제)
|
||||
- 유효성 검사 패턴 통합
|
||||
|
||||
#### 📝 레거시 페이지 마이그레이션
|
||||
**현황**: ~40-50개 파일이 PageLayout/PageHeader 직접 사용
|
||||
**대상 파일** (샘플):
|
||||
- `SubscriptionClient.tsx`
|
||||
- `SubscriptionManagement.tsx`
|
||||
- `ComprehensiveAnalysis/index.tsx`
|
||||
- `DailyReport/index.tsx`
|
||||
- `ReceivablesStatus/index.tsx`
|
||||
- `FAQManagement/FAQList.tsx`
|
||||
- `DepartmentManagement/index.tsx`
|
||||
- 등등
|
||||
|
||||
---
|
||||
|
||||
### 4.2 우선순위 중간 (Medium Priority)
|
||||
|
||||
#### 🗑️ 삭제 확인 다이얼로그 통합
|
||||
**현황**: 각 컴포넌트에서 AlertDialog 반복 구현
|
||||
**제안**:
|
||||
```typescript
|
||||
// 제안: useDeleteConfirm hook
|
||||
const { openDeleteConfirm, DeleteConfirmDialog } = useDeleteConfirm({
|
||||
title: '삭제 확인',
|
||||
description: '정말 삭제하시겠습니까?',
|
||||
onConfirm: handleDelete,
|
||||
});
|
||||
|
||||
// 또는 공통 컴포넌트
|
||||
<DeleteConfirmDialog
|
||||
isOpen={isOpen}
|
||||
itemName={itemName}
|
||||
onConfirm={handleDelete}
|
||||
onCancel={() => setIsOpen(false)}
|
||||
/>
|
||||
```
|
||||
|
||||
#### 📁 파일 업로드/다운로드 패턴 통합
|
||||
**현황**: 여러 컴포넌트에서 파일 처리 로직 중복
|
||||
**제안**:
|
||||
```typescript
|
||||
// 제안: useFileUpload hook
|
||||
const { uploadFile, downloadFile, FileDropzone } = useFileUpload({
|
||||
accept: ['image/*', '.pdf'],
|
||||
maxSize: 10 * 1024 * 1024,
|
||||
});
|
||||
```
|
||||
|
||||
#### 🔄 로딩 상태 표시 통합
|
||||
**현황**: 43개 파일에서 다양한 로딩 패턴 사용
|
||||
**제안**:
|
||||
- `LoadingOverlay` 컴포넌트 확대 적용
|
||||
- `Skeleton` 패턴 표준화
|
||||
|
||||
---
|
||||
|
||||
### 4.3 우선순위 낮음 (Low Priority)
|
||||
|
||||
#### 📊 대시보드 카드 컴포넌트
|
||||
**현황**: CEO 대시보드, 생산 대시보드 등에서 유사 패턴
|
||||
**제안**: `DashboardCard`, `StatCard` 공통 컴포넌트
|
||||
|
||||
#### 🔍 검색/필터 패턴
|
||||
**현황**: IntegratedListTemplateV2에 이미 통합됨
|
||||
**추가**: 독립 검색 컴포넌트 표준화
|
||||
|
||||
---
|
||||
|
||||
## 5. 레거시 파일 정리 대상
|
||||
|
||||
### 5.1 _legacy 폴더 (삭제 검토)
|
||||
```
|
||||
src/components/hr/CardManagement/_legacy/
|
||||
- CardDetail.tsx
|
||||
- CardForm.tsx
|
||||
|
||||
src/components/settings/AccountManagement/_legacy/
|
||||
- AccountDetail.tsx
|
||||
```
|
||||
|
||||
### 5.2 V1/V2 중복 파일 (통합 검토)
|
||||
- `LaborDetailClient.tsx` vs `LaborDetailClientV2.tsx`
|
||||
- `PricingDetailClient.tsx` vs `PricingDetailClientV2.tsx`
|
||||
- `DepositDetail.tsx` vs `DepositDetailClientV2.tsx`
|
||||
- `WithdrawalDetail.tsx` vs `WithdrawalDetailClientV2.tsx`
|
||||
|
||||
---
|
||||
|
||||
## 6. 권장 액션 플랜
|
||||
|
||||
### Phase 7: 레거시 페이지 마이그레이션
|
||||
| 순서 | 대상 | 예상 작업량 |
|
||||
|------|------|------------|
|
||||
| 1 | 설정 관리 페이지 (8개) | 중간 |
|
||||
| 2 | 회계 관리 페이지 (5개) | 중간 |
|
||||
| 3 | 인사 관리 페이지 (5개) | 중간 |
|
||||
| 4 | 보고서/분석 페이지 (3개) | 낮음 |
|
||||
|
||||
### Phase 8: Form 템플릿 개발
|
||||
1. IntegratedFormTemplate 설계
|
||||
2. 파일럿 적용 (2-3개 Form)
|
||||
3. 점진적 마이그레이션
|
||||
|
||||
### Phase 9: 유틸리티 Hook 개발
|
||||
1. useDeleteConfirm
|
||||
2. useFileUpload
|
||||
3. useFormState (공통 폼 상태 관리)
|
||||
|
||||
### Phase 10: 레거시 정리
|
||||
1. _legacy 폴더 삭제
|
||||
2. V1/V2 중복 파일 통합
|
||||
3. 미사용 컴포넌트 정리
|
||||
|
||||
---
|
||||
|
||||
## 7. 결론
|
||||
|
||||
### 공통화 성과
|
||||
- **상세 페이지**: 62% 공통화 달성 (Phase 6 완료)
|
||||
- **목록 페이지**: 82% 공통화 달성
|
||||
- **UI 컴포넌트**: Radix UI 기반 일관성 확보
|
||||
- **토스트/알림**: Sonner로 완전 통합
|
||||
|
||||
### 남은 과제
|
||||
- **Form 템플릿**: 72개 폼 컴포넌트 공통화 필요
|
||||
- **레거시 페이지**: ~40-50개 마이그레이션 필요
|
||||
- **코드 정리**: _legacy, V1/V2 중복 파일 정리
|
||||
|
||||
### 예상 효과 (추가 공통화 시)
|
||||
- 코드 중복 30% 추가 감소
|
||||
- 신규 페이지 개발 시간 50% 단축
|
||||
- 유지보수성 대폭 향상
|
||||
158
claudedocs/[ANALYSIS] common-component-patterns.md
Normal file
158
claudedocs/[ANALYSIS] common-component-patterns.md
Normal file
@@ -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에서 동일 패턴 확인 (파일 업로드/목록), 삭제 확인 다이얼로그 패턴 추가
|
||||
@@ -0,0 +1,137 @@
|
||||
# IntegratedDetailTemplate 마이그레이션 체크리스트
|
||||
|
||||
## 목표
|
||||
- 타이틀/버튼 영역(목록, 상세, 취소, 수정) 공통화
|
||||
- 반응형 입력 필드 통합
|
||||
- 특수 기능(테이블, 모달, 문서 미리보기 등)은 renderView/renderForm으로 유지
|
||||
|
||||
## 마이그레이션 패턴
|
||||
```typescript
|
||||
// 1. config 파일 생성
|
||||
export const xxxConfig: DetailConfig = {
|
||||
title: '페이지 타이틀',
|
||||
description: '설명',
|
||||
icon: IconComponent,
|
||||
basePath: '/path/to/list',
|
||||
fields: [], // renderView/renderForm 사용 시 빈 배열
|
||||
gridColumns: 2,
|
||||
actions: {
|
||||
showBack: true,
|
||||
showDelete: true/false,
|
||||
showEdit: true/false,
|
||||
// ... labels
|
||||
},
|
||||
};
|
||||
|
||||
// 2. 컴포넌트에서 IntegratedDetailTemplate 사용
|
||||
<IntegratedDetailTemplate
|
||||
config={dynamicConfig}
|
||||
mode={mode}
|
||||
initialData={data}
|
||||
itemId={id}
|
||||
isLoading={isLoading}
|
||||
onSubmit={handleSubmit} // Promise<{ success: boolean; error?: string }>
|
||||
onDelete={handleDelete} // Promise<{ success: boolean; error?: string }>
|
||||
headerActions={customHeaderActions} // 커스텀 버튼
|
||||
renderView={() => renderContent()}
|
||||
renderForm={() => renderContent()}
|
||||
/>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 적용 현황
|
||||
|
||||
### ✅ 완료 (Phase 6)
|
||||
|
||||
| No | 카테고리 | 컴포넌트 | 파일 | 특이사항 |
|
||||
|----|---------|---------|------|----------|
|
||||
| 1 | 건설/시공 | 협력업체 | PartnerForm.tsx | - |
|
||||
| 2 | 건설/시공 | 시공관리 | ConstructionDetailClient.tsx | - |
|
||||
| 3 | 건설/시공 | 기성관리 | ProgressBillingDetailForm.tsx | - |
|
||||
| 4 | 건설/시공 | 발주관리 | OrderDetailForm.tsx | - |
|
||||
| 5 | 건설/시공 | 계약관리 | ContractDetailForm.tsx | - |
|
||||
| 6 | 건설/시공 | 인수인계보고서 | HandoverReportDetailForm.tsx | - |
|
||||
| 7 | 건설/시공 | 견적관리 | EstimateDetailForm.tsx | - |
|
||||
| 8 | 건설/시공 | 현장브리핑 | SiteBriefingForm.tsx | - |
|
||||
| 9 | 건설/시공 | 이슈관리 | IssueDetailForm.tsx | - |
|
||||
| 10 | 건설/시공 | 입찰관리 | BiddingDetailForm.tsx | - |
|
||||
| 11 | 영업 | 견적관리(V2) | QuoteRegistrationV2.tsx | hideHeader prop, 자동견적/푸터바 유지 |
|
||||
| 12 | 영업 | 고객관리(V2) | ClientDetailClientV2.tsx | - |
|
||||
| 13 | 회계 | 청구관리 | BillDetail.tsx | - |
|
||||
| 14 | 회계 | 매입관리 | PurchaseDetail.tsx | - |
|
||||
| 15 | 회계 | 매출관리 | SalesDetail.tsx | - |
|
||||
| 16 | 회계 | 거래처관리 | VendorDetail.tsx | - |
|
||||
| 17 | 회계 | 입금관리(V2) | DepositDetailClientV2.tsx | - |
|
||||
| 18 | 회계 | 출금관리(V2) | WithdrawalDetailClientV2.tsx | - |
|
||||
| 19 | 생산 | 작업지시 | WorkOrderDetail.tsx | 상태변경버튼, 작업일지 모달 유지 |
|
||||
| 20 | 품질 | 검수관리 | InspectionDetail.tsx | 성적서 버튼 |
|
||||
| 21 | 출고 | 출하관리 | ShipmentDetail.tsx | 문서 미리보기 모달, 조건부 수정/삭제 |
|
||||
| 22 | 기준정보 | 단가관리(V2) | PricingDetailClientV2.tsx | - |
|
||||
| 23 | 기준정보 | 노무관리(V2) | LaborDetailClientV2.tsx | - |
|
||||
| 24 | 설정 | 팝업관리(V2) | PopupDetailClientV2.tsx | - |
|
||||
| 25 | 설정 | 계정관리 | accounts/[id]/page.tsx | - |
|
||||
| 26 | 설정 | 공정관리 | process-management/[id]/page.tsx | - |
|
||||
| 27 | 설정 | 게시판관리 | board-management/[id]/page.tsx | - |
|
||||
| 28 | 인사 | 명함관리 | card-management/[id]/page.tsx | - |
|
||||
| 29 | 영업 | 수주관리 | OrderSalesDetailView.tsx, OrderSalesDetailEdit.tsx | 문서 모달, 상태별 버튼, 확정/취소 다이얼로그 유지 |
|
||||
| 30 | 자재 | 입고관리 | ReceivingDetail.tsx | 입고증/입고처리/성공 다이얼로그, 상태별 버튼 |
|
||||
| 31 | 자재 | 재고현황 | StockStatusDetail.tsx | LOT별 상세 재고 테이블, FIFO 권장 메시지 |
|
||||
| 32 | 회계 | 악성채권 | BadDebtDetail.tsx | 저장 확인 다이얼로그, 파일 업로드/다운로드 |
|
||||
| 33 | 회계 | 거래처원장 | VendorLedgerDetail.tsx | 기간선택, PDF 다운로드, 판매/수금 테이블 |
|
||||
| 34 | 건설 | 구조검토 | StructureReviewDetailForm.tsx | view/edit/new 모드, 파일 드래그앤드롭 |
|
||||
| 35 | 건설 | 현장관리 | SiteDetailForm.tsx | 다음 우편번호 API, 파일 드래그앤드롭 |
|
||||
| 36 | 건설 | 품목관리 | ItemDetailClient.tsx | view/edit/new 모드, 동적 발주 항목 리스트 |
|
||||
| 37 | 고객센터 | 문의관리 | InquiryDetail.tsx | 댓글 CRUD, 작성자/상태별 버튼 표시 |
|
||||
| 38 | 고객센터 | 이벤트관리 | EventDetail.tsx | view 모드만 |
|
||||
| 39 | 고객센터 | 공지관리 | NoticeDetail.tsx | view 모드만, 이미지/첨부파일 |
|
||||
| 40 | 인사 | 직원관리 | EmployeeDetail.tsx | 기본정보/인사정보/사용자정보 카드 |
|
||||
| 41 | 설정 | 권한관리 | PermissionDetail.tsx | 인라인 수정, 메뉴별 권한 테이블, 자동 저장 |
|
||||
|
||||
---
|
||||
|
||||
## Config 파일 위치
|
||||
|
||||
| 컴포넌트 | Config 파일 |
|
||||
|---------|------------|
|
||||
| 출하관리 | shipmentConfig.ts |
|
||||
| 작업지시 | workOrderConfig.ts |
|
||||
| 검수관리 | inspectionConfig.ts |
|
||||
| 견적관리(V2) | quoteConfig.ts |
|
||||
| 수주관리 | orderSalesConfig.ts |
|
||||
| 입고관리 | receivingConfig.ts |
|
||||
| 재고현황 | stockStatusConfig.ts |
|
||||
| 악성채권 | badDebtConfig.ts |
|
||||
| 거래처원장 | vendorLedgerConfig.ts |
|
||||
| 구조검토 | structureReviewConfig.ts |
|
||||
| 현장관리 | siteConfig.ts |
|
||||
| 품목관리 | itemConfig.ts |
|
||||
| 문의관리 | inquiryConfig.ts |
|
||||
| 이벤트관리 | eventConfig.ts |
|
||||
| 공지관리 | noticeConfig.ts |
|
||||
| 직원관리 | employeeConfig.ts |
|
||||
| 권한관리 | permissionConfig.ts |
|
||||
|
||||
---
|
||||
|
||||
## 작업 로그
|
||||
|
||||
### 2026-01-20
|
||||
- Phase 6 마이그레이션 시작
|
||||
- 검수관리, 작업지시, 출하관리 완료
|
||||
- 견적관리(V2 테스트) 완료 - hideHeader 패턴 적용
|
||||
- 수주관리 완료 - OrderSalesDetailView.tsx, OrderSalesDetailEdit.tsx 마이그레이션
|
||||
- 입고관리 완료 - ReceivingDetail.tsx 마이그레이션
|
||||
- 재고현황 완료 - StockStatusDetail.tsx 마이그레이션 (LOT 테이블, FIFO 권장 메시지)
|
||||
- 악성채권 완료 - BadDebtDetail.tsx 마이그레이션 (저장 확인 다이얼로그, 파일 업로드/다운로드)
|
||||
- 거래처원장 완료 - VendorLedgerDetail.tsx 마이그레이션 (기간선택, PDF 다운로드, 판매/수금 테이블)
|
||||
- 구조검토 완료 - StructureReviewDetailForm.tsx 마이그레이션 (view/edit/new 모드, 파일 드래그앤드롭)
|
||||
- 현장관리 완료 - SiteDetailForm.tsx 마이그레이션 (다음 우편번호 API, 파일 드래그앤드롭)
|
||||
- 품목관리 완료 - ItemDetailClient.tsx 마이그레이션 (view/edit/new 모드, 동적 발주 항목 리스트)
|
||||
- 프로젝트관리 제외 - 칸반보드 형태라 IntegratedDetailTemplate 대상 아님
|
||||
- 문의관리 완료 - InquiryDetail.tsx 마이그레이션 (댓글 CRUD, 작성자/상태별 버튼 표시)
|
||||
- 이벤트관리 완료 - EventDetail.tsx 마이그레이션 (view 모드만)
|
||||
- 공지관리 완료 - NoticeDetail.tsx 마이그레이션 (view 모드만, 이미지/첨부파일)
|
||||
- 직원관리 완료 - EmployeeDetail.tsx 마이그레이션 (기본정보/인사정보/사용자정보 카드)
|
||||
- 권한관리 완료 - PermissionDetail.tsx 마이그레이션 (인라인 수정, 메뉴별 권한 테이블, 자동 저장, AlertDialog 유지)
|
||||
- **Phase 6 마이그레이션 완료** - 총 41개 컴포넌트 마이그레이션 완료
|
||||
File diff suppressed because it is too large
Load Diff
145
claudedocs/[REF] items-route-consolidation.md
Normal file
145
claudedocs/[REF] items-route-consolidation.md
Normal file
@@ -0,0 +1,145 @@
|
||||
# 품목관리 경로 통합 이슈 정리
|
||||
|
||||
> 작성일: 2026-01-20
|
||||
> 브랜치: `feature/universal-detail-component`
|
||||
> 커밋: `6f457b2`
|
||||
|
||||
---
|
||||
|
||||
## 문제 발견
|
||||
|
||||
### 증상
|
||||
- `/production/screen-production` 경로에서 품목 **등록 실패**
|
||||
- `/production/screen-production` 경로에서 품목 **수정 시 기존 값 미표시**
|
||||
|
||||
### 원인 분석
|
||||
|
||||
**중복 경로 존재:**
|
||||
```
|
||||
/items → 신버전 (DynamicItemForm)
|
||||
/production/screen-production → 구버전 (ItemForm)
|
||||
```
|
||||
|
||||
**백엔드 메뉴 설정:**
|
||||
- 사이드바 "생산관리 > 품목관리" 클릭 시 → `/production/screen-production`으로 연결
|
||||
- 메뉴 URL이 API에서 동적으로 관리되어 프론트에서 직접 변경 불가
|
||||
|
||||
**결과:**
|
||||
- 사용자는 항상 `/production/screen-production` (구버전 폼)으로 접속
|
||||
- 구버전 `ItemForm`은 API 필드 매핑이 맞지 않아 등록/수정 오류 발생
|
||||
- 신버전 `DynamicItemForm` (`/items`)은 정상 작동하지만 접근 경로 없음
|
||||
|
||||
---
|
||||
|
||||
## 파일 비교
|
||||
|
||||
### 등록 페이지 (create/page.tsx)
|
||||
|
||||
| 항목 | `/items/create` | `/production/screen-production/create` |
|
||||
|------|-----------------|---------------------------------------|
|
||||
| 폼 컴포넌트 | `DynamicItemForm` | `ItemForm` |
|
||||
| 폼 타입 | 동적 (품목기준관리 API) | 정적 (하드코딩) |
|
||||
| API 매핑 | 정상 | 불일치 |
|
||||
| 상태 | ✅ 정상 작동 | ❌ 등록 오류 |
|
||||
|
||||
### 목록/상세 페이지
|
||||
|
||||
| 항목 | `/items` | `/production/screen-production` |
|
||||
|------|----------|--------------------------------|
|
||||
| 목록 | `ItemListClient` | `ItemListClient` |
|
||||
| 상세 | `ItemDetailView` | `ItemDetailView` |
|
||||
| 수정 | `ItemDetailEdit` | `ItemDetailEdit` |
|
||||
| 상태 | 동일 컴포넌트 공유 | 동일 컴포넌트 공유 |
|
||||
|
||||
**결론:** 목록/상세/수정은 같은 컴포넌트를 공유하지만, **등록만 다른 폼**이 연결되어 있었음
|
||||
|
||||
---
|
||||
|
||||
## 해결 방법
|
||||
|
||||
### 선택지
|
||||
|
||||
1. **백엔드 메뉴 URL 변경**: `/production/screen-production` → `/items`
|
||||
- 백엔드 DB 수정 필요
|
||||
- 프론트 단독 작업 불가
|
||||
|
||||
2. **프론트 경로 통합**: `/items` 파일들을 `/production/screen-production`으로 이동 ✅
|
||||
- 백엔드 수정 불필요
|
||||
- 프론트 단독으로 해결 가능
|
||||
|
||||
### 적용한 해결책
|
||||
|
||||
**`/items` → `/production/screen-production` 파일 이동 및 통합**
|
||||
|
||||
```bash
|
||||
# 1. 기존 screen-production 삭제
|
||||
rm -rf src/app/[locale]/(protected)/production/screen-production
|
||||
|
||||
# 2. items 파일들을 screen-production으로 복사
|
||||
cp -r src/app/[locale]/(protected)/items/* \
|
||||
src/app/[locale]/(protected)/production/screen-production/
|
||||
|
||||
# 3. items 폴더 삭제
|
||||
rm -rf src/app/[locale]/(protected)/items
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 수정된 파일
|
||||
|
||||
### 라우트 파일 (삭제)
|
||||
- `src/app/[locale]/(protected)/items/page.tsx`
|
||||
- `src/app/[locale]/(protected)/items/create/page.tsx`
|
||||
- `src/app/[locale]/(protected)/items/[id]/page.tsx`
|
||||
- `src/app/[locale]/(protected)/items/[id]/edit/page.tsx`
|
||||
|
||||
### 라우트 파일 (신버전으로 교체)
|
||||
- `src/app/[locale]/(protected)/production/screen-production/page.tsx`
|
||||
- `src/app/[locale]/(protected)/production/screen-production/create/page.tsx`
|
||||
- `src/app/[locale]/(protected)/production/screen-production/[id]/page.tsx`
|
||||
- `src/app/[locale]/(protected)/production/screen-production/[id]/edit/page.tsx`
|
||||
|
||||
### 컴포넌트 경로 참조 수정 (`/items` → `/production/screen-production`)
|
||||
| 파일 | 수정 개수 |
|
||||
|------|----------|
|
||||
| `ItemListClient.tsx` | 3개 |
|
||||
| `ItemForm/index.tsx` | 1개 |
|
||||
| `ItemDetailClient.tsx` | 1개 |
|
||||
| `ItemDetailEdit.tsx` | 2개 |
|
||||
| `DynamicItemForm/index.tsx` | 2개 |
|
||||
| **합계** | **9개** |
|
||||
|
||||
---
|
||||
|
||||
## 교훈
|
||||
|
||||
### 문제 원인
|
||||
- 템플릿/테스트용 페이지에 메뉴를 연결한 채로 방치
|
||||
- 신버전 개발 시 구버전 경로 정리 누락
|
||||
- 두 경로가 같은 컴포넌트 일부를 공유해서 문제 파악 지연
|
||||
|
||||
### 예방책
|
||||
1. 신버전 개발 완료 시 구버전 경로 즉시 삭제 또는 리다이렉트 처리
|
||||
2. 메뉴 URL과 실제 라우트 파일 매핑 정기 점검
|
||||
3. 중복 경로 생성 시 명확한 용도 구분 및 문서화
|
||||
|
||||
---
|
||||
|
||||
## 최종 상태
|
||||
|
||||
```
|
||||
/production/screen-production → DynamicItemForm (신버전)
|
||||
/items → 삭제됨
|
||||
```
|
||||
|
||||
**품목관리 CRUD 테스트 결과:**
|
||||
|
||||
| 품목 유형 | Create | Read | Update | Delete |
|
||||
|-----------|--------|------|--------|--------|
|
||||
| 소모품(CS) | ✅ | ✅ | ✅ | ✅ |
|
||||
| 원자재(RM) | ✅ | ✅ | ✅ | ✅ |
|
||||
| 부자재(SM) | ✅ | ✅ | ✅ | ✅ |
|
||||
| 부품-구매(PT) | ✅ | ✅ | ✅ | ✅ |
|
||||
| 부품-절곡(PT) | ✅ | ✅ | ✅ | ✅ |
|
||||
| 부품-조립(PT) | ✅ | ✅ | ✅ | ✅ |
|
||||
| 제품(FG) | ✅ | ✅ | ✅ | ✅ |
|
||||
155
claudedocs/dev/[PLAN] detail-page-pattern-classification.md
Normal file
155
claudedocs/dev/[PLAN] detail-page-pattern-classification.md
Normal file
@@ -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 확인 완료
|
||||
118
claudedocs/dev/[REF] chrome-devtools-mcp-emoji-issue.md
Normal file
118
claudedocs/dev/[REF] chrome-devtools-mcp-emoji-issue.md
Normal file
@@ -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에 직접 렌더링된 이모지**: `<span>🏠</span>`
|
||||
2. **데이터에 포함된 이모지**: API 응답, 파싱된 데이터
|
||||
3. **대량의 이모지**: 수십 개 이상의 이모지가 한 페이지에 존재
|
||||
|
||||
## 해결 방법
|
||||
|
||||
### 1. 이모지를 Lucide 아이콘으로 교체 (UI)
|
||||
|
||||
**Before**
|
||||
```tsx
|
||||
const iconMap = {
|
||||
'기본': '🏠',
|
||||
'인사관리': '👥',
|
||||
};
|
||||
|
||||
<span className="text-xl">{category.icon}</span>
|
||||
```
|
||||
|
||||
**After**
|
||||
```tsx
|
||||
import { Home, Users, type LucideIcon } from 'lucide-react';
|
||||
|
||||
const iconComponents: Record<string, LucideIcon> = {
|
||||
Home,
|
||||
Users,
|
||||
};
|
||||
|
||||
function CategoryIcon({ name }: { name: string }) {
|
||||
const IconComponent = iconComponents[name] || FileText;
|
||||
return <IconComponent className="w-5 h-5" />;
|
||||
}
|
||||
|
||||
<CategoryIcon name={category.icon} />
|
||||
```
|
||||
|
||||
### 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 도구에서도 비슷한 문제 발생 가능성 있음
|
||||
@@ -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<BadDebtRecord | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(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 (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-muted-foreground">로딩 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !data) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[400px] gap-4">
|
||||
<div className="text-muted-foreground">{error || '데이터를 찾을 수 없습니다.'}</div>
|
||||
<button
|
||||
onClick={() => router.back()}
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
뒤로 가기
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <BadDebtDetail mode="edit" recordId={id} initialData={data} />;
|
||||
}
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-muted-foreground">리다이렉트 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<BadDebtRecord | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
getBadDebtById(id)
|
||||
.then(result => {
|
||||
if (result) {
|
||||
setData(result);
|
||||
} else {
|
||||
setError('데이터를 찾을 수 없습니다.');
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
setError('데이터를 불러오는 중 오류가 발생했습니다.');
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
}, [id]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-muted-foreground">로딩 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !data) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[400px] gap-4">
|
||||
<div className="text-muted-foreground">{error || '데이터를 찾을 수 없습니다.'}</div>
|
||||
<button
|
||||
onClick={() => router.back()}
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
뒤로 가기
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <BadDebtDetail mode="view" recordId={id} initialData={data} />;
|
||||
}
|
||||
return <BadDebtDetailClientV2 recordId={id} />;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { BadDebtDetail } from '@/components/accounting/BadDebtCollection/BadDebtDetail';
|
||||
import { BadDebtDetailClientV2 } from '@/components/accounting/BadDebtCollection';
|
||||
|
||||
export default function NewBadDebtPage() {
|
||||
return <BadDebtDetail mode="new" />;
|
||||
}
|
||||
return <BadDebtDetailClientV2 />;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
'use client';
|
||||
|
||||
import { use, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* V2 하위 호환성: /[id]/edit → /[id]?mode=edit 리다이렉트
|
||||
*/
|
||||
export default function DepositEditPage({ params }: PageProps) {
|
||||
const { id } = use(params);
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
router.replace(`/accounting/deposits/${id}?mode=edit`);
|
||||
}, [id, router]);
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-muted-foreground">리다이렉트 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,22 @@
|
||||
'use client';
|
||||
|
||||
import { useParams, useSearchParams } from 'next/navigation';
|
||||
import { DepositDetail } from '@/components/accounting/DepositManagement/DepositDetail';
|
||||
/**
|
||||
* 입금 상세/수정 페이지 (Client Component)
|
||||
* V2 패턴: ?mode=edit로 수정 모드 전환
|
||||
*/
|
||||
|
||||
export default function DepositDetailPage() {
|
||||
const params = useParams();
|
||||
import { use } from 'react';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import DepositDetailClientV2 from '@/components/accounting/DepositManagement/DepositDetailClientV2';
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export default function DepositDetailPage({ params }: PageProps) {
|
||||
const { id } = use(params);
|
||||
const searchParams = useSearchParams();
|
||||
const depositId = params.id as string;
|
||||
const mode = searchParams.get('mode') === 'edit' ? 'edit' : 'view';
|
||||
|
||||
return <DepositDetail depositId={depositId} mode={mode} />;
|
||||
return <DepositDetailClientV2 depositId={id} initialMode={mode} />;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import DepositDetailClientV2 from '@/components/accounting/DepositManagement/DepositDetailClientV2';
|
||||
|
||||
export default function DepositNewPage() {
|
||||
return <DepositDetailClientV2 initialMode="create" />;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
'use client';
|
||||
|
||||
import { use, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* V2 하위 호환성: /[id]/edit → /[id]?mode=edit 리다이렉트
|
||||
*/
|
||||
export default function WithdrawalEditPage({ params }: PageProps) {
|
||||
const { id } = use(params);
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
router.replace(`/accounting/withdrawals/${id}?mode=edit`);
|
||||
}, [id, router]);
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-muted-foreground">리다이렉트 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,22 @@
|
||||
'use client';
|
||||
|
||||
import { useParams, useSearchParams } from 'next/navigation';
|
||||
import { WithdrawalDetail } from '@/components/accounting/WithdrawalManagement/WithdrawalDetail';
|
||||
/**
|
||||
* 출금 상세/수정 페이지 (Client Component)
|
||||
* V2 패턴: ?mode=edit로 수정 모드 전환
|
||||
*/
|
||||
|
||||
export default function WithdrawalDetailPage() {
|
||||
const params = useParams();
|
||||
import { use } from 'react';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import WithdrawalDetailClientV2 from '@/components/accounting/WithdrawalManagement/WithdrawalDetailClientV2';
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export default function WithdrawalDetailPage({ params }: PageProps) {
|
||||
const { id } = use(params);
|
||||
const searchParams = useSearchParams();
|
||||
const withdrawalId = params.id as string;
|
||||
const mode = searchParams.get('mode') === 'edit' ? 'edit' : 'view';
|
||||
|
||||
return <WithdrawalDetail withdrawalId={withdrawalId} mode={mode} />;
|
||||
return <WithdrawalDetailClientV2 withdrawalId={id} initialMode={mode} />;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import WithdrawalDetailClientV2 from '@/components/accounting/WithdrawalManagement/WithdrawalDetailClientV2';
|
||||
|
||||
export default function WithdrawalNewPage() {
|
||||
return <WithdrawalDetailClientV2 initialMode="create" />;
|
||||
}
|
||||
@@ -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<Board | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(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 <ContentLoadingSpinner text="게시판 정보를 불러오는 중..." />;
|
||||
}
|
||||
|
||||
// 에러 상태
|
||||
if (error && !board) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[400px] gap-4">
|
||||
<p className="text-destructive">{error}</p>
|
||||
<Button onClick={() => router.push('/ko/board/board-management')} variant="outline">
|
||||
목록으로
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!board) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[400px] gap-4">
|
||||
<p className="text-destructive">게시판을 찾을 수 없습니다.</p>
|
||||
<Button onClick={() => router.push('/ko/board/board-management')} variant="outline">
|
||||
목록으로
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{error && (
|
||||
<div className="mb-4 p-4 bg-destructive/10 border border-destructive/20 rounded-md">
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
<BoardForm
|
||||
mode="edit"
|
||||
board={board}
|
||||
onSubmit={handleSubmit}
|
||||
/>
|
||||
{isSubmitting && (
|
||||
<div className="fixed inset-0 bg-background/50 flex items-center justify-center z-50">
|
||||
<div className="flex items-center gap-2 bg-background p-4 rounded-md shadow-lg">
|
||||
<Loader2 className="w-5 h-5 animate-spin" />
|
||||
<span>저장 중...</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
return <ContentLoadingSpinner text="페이지 이동 중..." />;
|
||||
}
|
||||
|
||||
@@ -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<Board | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(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 <ContentLoadingSpinner text="게시판 정보를 불러오는 중..." />;
|
||||
}
|
||||
|
||||
// 에러 상태
|
||||
if (error || !board) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[400px] gap-4">
|
||||
<p className="text-destructive">{error || '게시판을 찾을 수 없습니다.'}</p>
|
||||
<Button onClick={() => router.push('/ko/board/board-management')} variant="outline">
|
||||
목록으로
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<BoardDetail
|
||||
board={board}
|
||||
onEdit={handleEdit}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
|
||||
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>게시판 삭제</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
"{board.boardName}" 게시판을 삭제하시겠습니까?
|
||||
<br />
|
||||
<span className="text-destructive">
|
||||
삭제된 게시판 정보는 복구할 수 없습니다.
|
||||
</span>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isDeleting}>취소</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={confirmDelete}
|
||||
disabled={isDeleting}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
{isDeleting ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
삭제 중...
|
||||
</>
|
||||
) : (
|
||||
'삭제'
|
||||
)}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
return <BoardDetailClientV2 boardId={id} />;
|
||||
}
|
||||
|
||||
@@ -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<string | null>(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 && (
|
||||
<div className="mb-4 p-4 bg-destructive/10 border border-destructive/20 rounded-md">
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
<BoardForm
|
||||
mode="create"
|
||||
onSubmit={handleSubmit}
|
||||
/>
|
||||
{isSubmitting && (
|
||||
<div className="fixed inset-0 bg-background/50 flex items-center justify-center z-50">
|
||||
<div className="flex items-center gap-2 bg-background p-4 rounded-md shadow-lg">
|
||||
<Loader2 className="w-5 h-5 animate-spin" />
|
||||
<span>등록 중...</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
export default function BoardCreatePage() {
|
||||
return <BoardDetailClientV2 boardId="new" />;
|
||||
}
|
||||
|
||||
@@ -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<Awaited<ReturnType<typeof getProgressBillingDetail>>['data']>(undefined);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(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 (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-muted-foreground">로딩 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !data) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[400px] gap-4">
|
||||
<div className="text-muted-foreground">{error || '기성청구 정보를 찾을 수 없습니다.'}</div>
|
||||
<button
|
||||
onClick={() => router.back()}
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
뒤로 가기
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <ProgressBillingDetailForm mode="edit" billingId={id} initialData={data} />;
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-muted-foreground">리다이렉트 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<Awaited<ReturnType<typeof getProgressBillingDetail>>['data']>(undefined);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(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 (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
@@ -41,17 +53,21 @@ export default function ProgressBillingDetailPage({ params }: ProgressBillingDet
|
||||
|
||||
if (error || !data) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[400px] gap-4">
|
||||
<div className="text-muted-foreground">{error || '기성청구 정보를 찾을 수 없습니다.'}</div>
|
||||
<button
|
||||
onClick={() => router.back()}
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
뒤로 가기
|
||||
</button>
|
||||
</div>
|
||||
<ServerErrorPage
|
||||
title="기성청구 정보를 불러올 수 없습니다"
|
||||
message={error || '기성청구 정보를 찾을 수 없습니다.'}
|
||||
onRetry={fetchData}
|
||||
showBackButton={true}
|
||||
showHomeButton={true}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <ProgressBillingDetailForm mode="view" billingId={id} initialData={data} />;
|
||||
return (
|
||||
<ProgressBillingDetailForm
|
||||
mode={isEditMode ? 'edit' : 'view'}
|
||||
billingId={id}
|
||||
initialData={data}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 <LaborDetailClient laborId={id} isEditMode={isEditMode} />;
|
||||
return <LaborDetailClientV2 laborId={id} initialMode={initialMode} />;
|
||||
}
|
||||
@@ -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 <LaborDetailClient isNewMode />;
|
||||
return <LaborDetailClientV2 initialMode="create" />;
|
||||
}
|
||||
@@ -1,14 +1,26 @@
|
||||
'use client';
|
||||
|
||||
import { use } from 'react';
|
||||
import PricingDetailClient from '@/components/business/construction/pricing-management/PricingDetailClient';
|
||||
import { use, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* V2 하위 호환성: /[id]/edit → /[id]?mode=edit 리다이렉트
|
||||
*/
|
||||
export default function PricingEditPage({ params }: PageProps) {
|
||||
const { id } = use(params);
|
||||
const router = useRouter();
|
||||
|
||||
return <PricingDetailClient id={id} mode="edit" />;
|
||||
useEffect(() => {
|
||||
router.replace(`/construction/order/base-info/pricing/${id}?mode=edit`);
|
||||
}, [id, router]);
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-muted-foreground">리다이렉트 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,13 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 단가 상세/수정 페이지 (Client Component)
|
||||
* V2 패턴: ?mode=edit로 수정 모드 전환
|
||||
*/
|
||||
|
||||
import { use } from 'react';
|
||||
import PricingDetailClient from '@/components/business/construction/pricing-management/PricingDetailClient';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { PricingDetailClientV2 } from '@/components/business/construction/pricing-management';
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
@@ -9,6 +15,8 @@ interface PageProps {
|
||||
|
||||
export default function PricingDetailPage({ params }: PageProps) {
|
||||
const { id } = use(params);
|
||||
const searchParams = useSearchParams();
|
||||
const mode = searchParams.get('mode') === 'edit' ? 'edit' : 'view';
|
||||
|
||||
return <PricingDetailClient id={id} mode="view" />;
|
||||
return <PricingDetailClientV2 pricingId={id} initialMode={mode} />;
|
||||
}
|
||||
@@ -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 <PricingDetailClient mode="create" />;
|
||||
return <PricingDetailClientV2 initialMode="create" />;
|
||||
}
|
||||
@@ -1,57 +1,26 @@
|
||||
'use client';
|
||||
|
||||
import { use, useEffect, useState } from 'react';
|
||||
import { use, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { OrderDetailForm } from '@/components/business/construction/order-management';
|
||||
import { getOrderDetailFull } from '@/components/business/construction/order-management/actions';
|
||||
|
||||
interface OrderEditPageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* V2 하위 호환성: /[id]/edit → /[id]?mode=edit 리다이렉트
|
||||
*/
|
||||
export default function OrderEditPage({ params }: OrderEditPageProps) {
|
||||
const { id } = use(params);
|
||||
const router = useRouter();
|
||||
const [data, setData] = useState<Awaited<ReturnType<typeof getOrderDetailFull>>['data']>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
getOrderDetailFull(id)
|
||||
.then(result => {
|
||||
if (result.success && result.data) {
|
||||
setData(result.data);
|
||||
} else {
|
||||
setError('주문 정보를 찾을 수 없습니다.');
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
setError('주문 정보를 불러오는 중 오류가 발생했습니다.');
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
}, [id]);
|
||||
router.replace(`/construction/order/order-management/${id}?mode=edit`);
|
||||
}, [id, router]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-muted-foreground">로딩 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !data) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[400px] gap-4">
|
||||
<div className="text-muted-foreground">{error || '주문 정보를 찾을 수 없습니다.'}</div>
|
||||
<button
|
||||
onClick={() => router.back()}
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
뒤로 가기
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <OrderDetailForm mode="edit" orderId={id} initialData={data} />;
|
||||
}
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-muted-foreground">리다이렉트 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 발주 상세/수정 페이지 (Client Component)
|
||||
* V2 패턴: ?mode=edit로 수정 모드 전환
|
||||
*/
|
||||
|
||||
import { use, useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { OrderDetailForm } from '@/components/business/construction/order-management';
|
||||
import { getOrderDetailFull } from '@/components/business/construction/order-management/actions';
|
||||
import { ServerErrorPage } from '@/components/common/ServerErrorPage';
|
||||
|
||||
interface OrderDetailPageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
@@ -12,6 +18,8 @@ interface OrderDetailPageProps {
|
||||
export default function OrderDetailPage({ params }: OrderDetailPageProps) {
|
||||
const { id } = use(params);
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const mode = searchParams.get('mode') === 'edit' ? 'edit' : 'view';
|
||||
const [data, setData] = useState<Awaited<ReturnType<typeof getOrderDetailFull>>['data']>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -41,17 +49,14 @@ export default function OrderDetailPage({ params }: OrderDetailPageProps) {
|
||||
|
||||
if (error || !data) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[400px] gap-4">
|
||||
<div className="text-muted-foreground">{error || '주문 정보를 찾을 수 없습니다.'}</div>
|
||||
<button
|
||||
onClick={() => router.back()}
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
뒤로 가기
|
||||
</button>
|
||||
</div>
|
||||
<ServerErrorPage
|
||||
title="발주 정보를 불러올 수 없습니다"
|
||||
message={error || '발주 정보를 찾을 수 없습니다.'}
|
||||
showBackButton={true}
|
||||
showHomeButton={true}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <OrderDetailForm mode="view" orderId={id} initialData={data} />;
|
||||
return <OrderDetailForm mode={mode} orderId={id} initialData={data} />;
|
||||
}
|
||||
@@ -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<typeof MOCK_SITE | null>(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 (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-muted-foreground">로딩 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <SiteDetailForm site={site} mode="edit" />;
|
||||
}
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-muted-foreground">리다이렉트 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<typeof MOCK_SITE | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
// TODO: API에서 현장 정보 조회
|
||||
setSite({ ...MOCK_SITE, id });
|
||||
setIsLoading(false);
|
||||
}, [id]);
|
||||
|
||||
if (isLoading || !site) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-muted-foreground">로딩 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <SiteDetailForm site={site} mode="view" />;
|
||||
}
|
||||
return <SiteDetailClientV2 siteId={id} />;
|
||||
}
|
||||
|
||||
@@ -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<typeof MOCK_REVIEW | null>(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 (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-muted-foreground">로딩 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <StructureReviewDetailForm review={review} mode="edit" />;
|
||||
}
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-muted-foreground">리다이렉트 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<typeof MOCK_REVIEW | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
// TODO: API에서 구조검토 정보 조회
|
||||
setReview({ ...MOCK_REVIEW, id });
|
||||
setIsLoading(false);
|
||||
}, [id]);
|
||||
|
||||
if (isLoading || !review) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-muted-foreground">로딩 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <StructureReviewDetailForm review={review} mode="view" />;
|
||||
return <StructureReviewDetailClientV2 reviewId={id} />;
|
||||
}
|
||||
|
||||
@@ -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<Awaited<ReturnType<typeof getBiddingDetail>>['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 (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-muted-foreground">로딩 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
router.replace(`/ko/construction/project/bidding/${id}?mode=edit`);
|
||||
}, [id, router]);
|
||||
|
||||
return (
|
||||
<BiddingDetailForm
|
||||
mode="edit"
|
||||
biddingId={id}
|
||||
initialData={data}
|
||||
/>
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-muted-foreground">리다이렉트 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Awaited<ReturnType<typeof getBiddingDetail>>['data']>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(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 (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
@@ -28,9 +50,21 @@ export default function BiddingDetailPage({ params }: BiddingDetailPageProps) {
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<ServerErrorPage
|
||||
title="입찰 정보를 불러올 수 없습니다"
|
||||
message={error}
|
||||
onRetry={fetchData}
|
||||
showBackButton={true}
|
||||
showHomeButton={true}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<BiddingDetailForm
|
||||
mode="view"
|
||||
mode={isEditMode ? 'edit' : 'view'}
|
||||
biddingId={id}
|
||||
initialData={data}
|
||||
/>
|
||||
|
||||
@@ -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<EstimateDetail | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(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 (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-muted-foreground">로딩 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-red-500">{error}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
router.replace(`/ko/construction/project/bidding/estimates/${id}?mode=edit`);
|
||||
}, [id, router]);
|
||||
|
||||
return (
|
||||
<EstimateDetailForm
|
||||
mode="edit"
|
||||
estimateId={id}
|
||||
initialData={data || undefined}
|
||||
/>
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-muted-foreground">리다이렉트 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<EstimateDetail | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(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 (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
@@ -45,15 +54,19 @@ export default function EstimateDetailPage({ params }: EstimateDetailPageProps)
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-red-500">{error}</div>
|
||||
</div>
|
||||
<ServerErrorPage
|
||||
title="견적 정보를 불러올 수 없습니다"
|
||||
message={error}
|
||||
onRetry={fetchData}
|
||||
showBackButton={true}
|
||||
showHomeButton={true}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<EstimateDetailForm
|
||||
mode="view"
|
||||
mode={isEditMode ? 'edit' : 'view'}
|
||||
estimateId={id}
|
||||
initialData={data || undefined}
|
||||
/>
|
||||
|
||||
@@ -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<Awaited<ReturnType<typeof getPartner>>['data']>(undefined);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(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 (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-muted-foreground">로딩 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[400px] gap-4">
|
||||
<div className="text-muted-foreground">{error}</div>
|
||||
<button onClick={() => router.back()} className="text-primary hover:underline">
|
||||
뒤로 가기
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
router.replace(`/ko/construction/project/bidding/partners/${id}?mode=edit`);
|
||||
}, [id, router]);
|
||||
|
||||
return (
|
||||
<PartnerForm
|
||||
mode="edit"
|
||||
partnerId={id}
|
||||
initialData={data}
|
||||
/>
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-muted-foreground">리다이렉트 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Awaited<ReturnType<typeof getPartner>>['data']>(undefined);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(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 (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
@@ -41,18 +53,19 @@ export default function PartnerDetailPage({ params }: PartnerDetailPageProps) {
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[400px] gap-4">
|
||||
<div className="text-muted-foreground">{error}</div>
|
||||
<button onClick={() => router.back()} className="text-primary hover:underline">
|
||||
뒤로 가기
|
||||
</button>
|
||||
</div>
|
||||
<ServerErrorPage
|
||||
title="협력업체 정보를 불러올 수 없습니다"
|
||||
message={error}
|
||||
onRetry={fetchData}
|
||||
showBackButton={true}
|
||||
showHomeButton={true}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PartnerForm
|
||||
mode="view"
|
||||
mode={isEditMode ? 'edit' : 'view'}
|
||||
partnerId={id}
|
||||
initialData={data}
|
||||
/>
|
||||
|
||||
@@ -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<Awaited<ReturnType<typeof getSiteBriefing>>['data']>(undefined);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(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 (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-muted-foreground">로딩 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[400px] gap-4">
|
||||
<div className="text-muted-foreground">{error}</div>
|
||||
<button onClick={() => router.back()} className="text-primary hover:underline">
|
||||
뒤로 가기
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
router.replace(`/ko/construction/project/bidding/site-briefings/${id}?mode=edit`);
|
||||
}, [id, router]);
|
||||
|
||||
return (
|
||||
<SiteBriefingForm
|
||||
mode="edit"
|
||||
briefingId={id}
|
||||
initialData={data}
|
||||
/>
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-muted-foreground">리다이렉트 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Awaited<ReturnType<typeof getSiteBriefing>>['data']>(undefined);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(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 (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
@@ -40,18 +52,19 @@ export default function SiteBriefingDetailPage({ params }: SiteBriefingDetailPag
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[400px] gap-4">
|
||||
<div className="text-muted-foreground">{error}</div>
|
||||
<button onClick={() => router.back()} className="text-primary hover:underline">
|
||||
뒤로 가기
|
||||
</button>
|
||||
</div>
|
||||
<ServerErrorPage
|
||||
title="현장 설명회 정보를 불러올 수 없습니다"
|
||||
message={error}
|
||||
onRetry={fetchData}
|
||||
showBackButton={true}
|
||||
showHomeButton={true}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SiteBriefingForm
|
||||
mode="view"
|
||||
mode={isEditMode ? 'edit' : 'view'}
|
||||
briefingId={id}
|
||||
initialData={data}
|
||||
/>
|
||||
|
||||
@@ -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 <ConstructionDetailClient id={id} mode="edit" />;
|
||||
}
|
||||
useEffect(() => {
|
||||
router.replace(`/ko/construction/project/construction-management/${id}?mode=edit`);
|
||||
}, [id, router]);
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-muted-foreground">리다이렉트 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 <ConstructionDetailClient id={id} mode="view" />;
|
||||
// V2 패턴: mode 체크
|
||||
const mode = searchParams.get('mode') || 'view';
|
||||
const isEditMode = mode === 'edit';
|
||||
|
||||
return <ConstructionDetailClient id={id} mode={isEditMode ? 'edit' : 'view'} />;
|
||||
}
|
||||
|
||||
@@ -1,60 +1,26 @@
|
||||
'use client';
|
||||
|
||||
import { use, useEffect, useState } from 'react';
|
||||
import { use, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import ContractDetailForm from '@/components/business/construction/contract/ContractDetailForm';
|
||||
import { getContractDetail } from '@/components/business/construction/contract';
|
||||
|
||||
interface ContractEditPageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* V2 하위 호환성: /[id]/edit → /[id]?mode=edit 리다이렉트
|
||||
*/
|
||||
export default function ContractEditPage({ params }: ContractEditPageProps) {
|
||||
const { id } = use(params);
|
||||
const router = useRouter();
|
||||
const [data, setData] = useState<Awaited<ReturnType<typeof getContractDetail>>['data']>(undefined);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
getContractDetail(id)
|
||||
.then(result => {
|
||||
if (result.success && result.data) {
|
||||
setData(result.data);
|
||||
} else {
|
||||
setError('계약 정보를 찾을 수 없습니다.');
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
setError('계약 정보를 불러오는 중 오류가 발생했습니다.');
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
}, [id]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-muted-foreground">로딩 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[400px] gap-4">
|
||||
<div className="text-muted-foreground">{error}</div>
|
||||
<button onClick={() => router.back()} className="text-primary hover:underline">
|
||||
뒤로 가기
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
router.replace(`/construction/project/contract/${id}?mode=edit`);
|
||||
}, [id, router]);
|
||||
|
||||
return (
|
||||
<ContractDetailForm
|
||||
mode="edit"
|
||||
contractId={id}
|
||||
initialData={data}
|
||||
/>
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-muted-foreground">리다이렉트 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
'use client';
|
||||
|
||||
import { use, useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import ContractDetailForm from '@/components/business/construction/contract/ContractDetailForm';
|
||||
import { getContractDetail } from '@/components/business/construction/contract';
|
||||
import { ServerErrorPage } from '@/components/common/ServerErrorPage';
|
||||
|
||||
interface ContractDetailPageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
@@ -12,6 +13,8 @@ interface ContractDetailPageProps {
|
||||
export default function ContractDetailPage({ params }: ContractDetailPageProps) {
|
||||
const { id } = use(params);
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const mode = searchParams.get('mode') === 'edit' ? 'edit' : 'view';
|
||||
const [data, setData] = useState<Awaited<ReturnType<typeof getContractDetail>>['data']>(undefined);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -41,18 +44,18 @@ export default function ContractDetailPage({ params }: ContractDetailPageProps)
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[400px] gap-4">
|
||||
<div className="text-muted-foreground">{error}</div>
|
||||
<button onClick={() => router.back()} className="text-primary hover:underline">
|
||||
뒤로 가기
|
||||
</button>
|
||||
</div>
|
||||
<ServerErrorPage
|
||||
title="계약 정보를 불러올 수 없습니다"
|
||||
message={error}
|
||||
showBackButton={true}
|
||||
showHomeButton={true}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ContractDetailForm
|
||||
mode="view"
|
||||
mode={mode}
|
||||
contractId={id}
|
||||
initialData={data}
|
||||
/>
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { use, useEffect, useState } from 'react';
|
||||
import { use, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { HandoverReportDetailForm, getHandoverReportDetail } from '@/components/business/construction/handover-report';
|
||||
|
||||
interface HandoverReportEditPageProps {
|
||||
params: Promise<{
|
||||
@@ -11,52 +10,20 @@ interface HandoverReportEditPageProps {
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* V2 하위 호환성: /[id]/edit → /[id]?mode=edit 리다이렉트
|
||||
*/
|
||||
export default function HandoverReportEditPage({ params }: HandoverReportEditPageProps) {
|
||||
const { id } = use(params);
|
||||
const router = useRouter();
|
||||
const [data, setData] = useState<Awaited<ReturnType<typeof getHandoverReportDetail>>['data']>(undefined);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
getHandoverReportDetail(id)
|
||||
.then(result => {
|
||||
if (result.data) {
|
||||
setData(result.data);
|
||||
} else {
|
||||
setError('인수인계서 정보를 찾을 수 없습니다.');
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
setError('인수인계서 정보를 불러오는 중 오류가 발생했습니다.');
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
}, [id]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-muted-foreground">로딩 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[400px] gap-4">
|
||||
<div className="text-muted-foreground">{error}</div>
|
||||
<button onClick={() => router.back()} className="text-primary hover:underline">
|
||||
뒤로 가기
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
router.replace(`/construction/project/contract/handover-report/${id}?mode=edit`);
|
||||
}, [id, router]);
|
||||
|
||||
return (
|
||||
<HandoverReportDetailForm
|
||||
mode="edit"
|
||||
reportId={id}
|
||||
initialData={data}
|
||||
/>
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-muted-foreground">리다이렉트 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
'use client';
|
||||
|
||||
import { use, useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { HandoverReportDetailForm, getHandoverReportDetail } from '@/components/business/construction/handover-report';
|
||||
import { ServerErrorPage } from '@/components/common/ServerErrorPage';
|
||||
|
||||
interface HandoverReportDetailPageProps {
|
||||
params: Promise<{
|
||||
@@ -14,6 +15,8 @@ interface HandoverReportDetailPageProps {
|
||||
export default function HandoverReportDetailPage({ params }: HandoverReportDetailPageProps) {
|
||||
const { id } = use(params);
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const mode = searchParams.get('mode') === 'edit' ? 'edit' : 'view';
|
||||
const [data, setData] = useState<Awaited<ReturnType<typeof getHandoverReportDetail>>['data']>(undefined);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -43,18 +46,18 @@ export default function HandoverReportDetailPage({ params }: HandoverReportDetai
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[400px] gap-4">
|
||||
<div className="text-muted-foreground">{error}</div>
|
||||
<button onClick={() => router.back()} className="text-primary hover:underline">
|
||||
뒤로 가기
|
||||
</button>
|
||||
</div>
|
||||
<ServerErrorPage
|
||||
title="인수인계서 정보를 불러올 수 없습니다"
|
||||
message={error}
|
||||
showBackButton={true}
|
||||
showHomeButton={true}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<HandoverReportDetailForm
|
||||
mode="view"
|
||||
mode={mode}
|
||||
reportId={id}
|
||||
initialData={data}
|
||||
/>
|
||||
|
||||
@@ -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<Issue | undefined>(undefined);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(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 (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-muted-foreground">로딩 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-red-500">{error}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <IssueDetailForm issue={issue} mode="edit" />;
|
||||
}
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-muted-foreground">리다이렉트 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<Issue | undefined>(undefined);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(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 (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
@@ -43,11 +51,15 @@ export default function IssueDetailPage() {
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-red-500">{error}</div>
|
||||
</div>
|
||||
<ServerErrorPage
|
||||
title="이슈 정보를 불러올 수 없습니다"
|
||||
message={error}
|
||||
onRetry={fetchData}
|
||||
showBackButton={true}
|
||||
showHomeButton={true}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <IssueDetailForm issue={issue} mode="view" />;
|
||||
return <IssueDetailForm issue={issue} mode={isEditMode ? 'edit' : 'view'} />;
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
'use client';
|
||||
|
||||
import { useParams } from 'next/navigation';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { EventDetail } from '@/components/customer-center/EventManagement';
|
||||
import { transformPostToEvent, type Event } from '@/components/customer-center/EventManagement/types';
|
||||
import { getPost } from '@/components/customer-center/shared/actions';
|
||||
import { ServerErrorPage } from '@/components/common/ServerErrorPage';
|
||||
|
||||
export default function EventDetailPage() {
|
||||
const params = useParams();
|
||||
@@ -14,38 +15,42 @@ export default function EventDetailPage() {
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchEvent() {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
const fetchEvent = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
const result = await getPost('events', eventId);
|
||||
const result = await getPost('events', eventId);
|
||||
|
||||
if (result.success && result.data) {
|
||||
setEvent(transformPostToEvent(result.data));
|
||||
} else {
|
||||
setError(result.error || '이벤트를 찾을 수 없습니다.');
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
if (result.success && result.data) {
|
||||
setEvent(transformPostToEvent(result.data));
|
||||
} else {
|
||||
setError(result.error || '이벤트를 찾을 수 없습니다.');
|
||||
}
|
||||
|
||||
fetchEvent();
|
||||
setIsLoading(false);
|
||||
}, [eventId]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchEvent();
|
||||
}, [fetchEvent]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<p className="text-muted-foreground">로딩 중...</p>
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-muted-foreground">로딩 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !event) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<p className="text-muted-foreground">{error || '이벤트를 찾을 수 없습니다.'}</p>
|
||||
</div>
|
||||
<ServerErrorPage
|
||||
title="이벤트를 불러올 수 없습니다"
|
||||
message={error || '이벤트를 찾을 수 없습니다.'}
|
||||
onRetry={fetchEvent}
|
||||
showBackButton={true}
|
||||
showHomeButton={true}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
'use client';
|
||||
|
||||
import { useParams } from 'next/navigation';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { NoticeDetail } from '@/components/customer-center/NoticeManagement';
|
||||
import { transformPostToNotice, type Notice } from '@/components/customer-center/NoticeManagement/types';
|
||||
import { getPost } from '@/components/customer-center/shared/actions';
|
||||
import { ServerErrorPage } from '@/components/common/ServerErrorPage';
|
||||
|
||||
export default function NoticeDetailPage() {
|
||||
const params = useParams();
|
||||
@@ -14,38 +15,42 @@ export default function NoticeDetailPage() {
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchNotice() {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
const fetchNotice = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
const result = await getPost('notices', id);
|
||||
const result = await getPost('notices', id);
|
||||
|
||||
if (result.success && result.data) {
|
||||
setNotice(transformPostToNotice(result.data));
|
||||
} else {
|
||||
setError(result.error || '공지사항을 찾을 수 없습니다.');
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
if (result.success && result.data) {
|
||||
setNotice(transformPostToNotice(result.data));
|
||||
} else {
|
||||
setError(result.error || '공지사항을 찾을 수 없습니다.');
|
||||
}
|
||||
|
||||
fetchNotice();
|
||||
setIsLoading(false);
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchNotice();
|
||||
}, [fetchNotice]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<p className="text-muted-foreground">로딩 중...</p>
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-muted-foreground">로딩 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !notice) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<p className="text-muted-foreground">{error || '공지사항을 찾을 수 없습니다.'}</p>
|
||||
</div>
|
||||
<ServerErrorPage
|
||||
title="공지사항을 불러올 수 없습니다"
|
||||
message={error || '공지사항을 찾을 수 없습니다.'}
|
||||
onRetry={fetchNotice}
|
||||
showBackButton={true}
|
||||
showHomeButton={true}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<Inquiry | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(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 (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<p className="text-muted-foreground">로딩 중...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !inquiry) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<p className="text-muted-foreground">{error || '문의를 찾을 수 없습니다.'}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <InquiryForm mode="edit" initialData={inquiry} />;
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-muted-foreground">리다이렉트 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<Inquiry | null>(null);
|
||||
const [comments, setComments] = useState<Comment[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [currentUserId, setCurrentUserId] = useState<string>('');
|
||||
|
||||
// 현재 사용자 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 (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<p className="text-muted-foreground">로딩 중...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !inquiry) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<p className="text-muted-foreground">{error || '문의를 찾을 수 없습니다.'}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 답변은 추후 API 추가 시 구현
|
||||
const reply = undefined;
|
||||
|
||||
return (
|
||||
<InquiryDetail
|
||||
inquiry={inquiry}
|
||||
reply={reply}
|
||||
comments={comments}
|
||||
currentUserId={currentUserId}
|
||||
onAddComment={handleAddComment}
|
||||
onUpdateComment={handleUpdateComment}
|
||||
onDeleteComment={handleDeleteComment}
|
||||
onDeleteInquiry={handleDeleteInquiry}
|
||||
/>
|
||||
);
|
||||
interface InquiryDetailPageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export default function InquiryDetailPage({ params }: InquiryDetailPageProps) {
|
||||
const { id } = use(params);
|
||||
return <InquiryDetailClientV2 inquiryId={id} />;
|
||||
}
|
||||
|
||||
@@ -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 <InquiryForm mode="create" />;
|
||||
return <InquiryDetailClientV2 />;
|
||||
}
|
||||
|
||||
export const metadata = {
|
||||
title: '1:1 문의 등록',
|
||||
description: '1:1 문의를 등록합니다.',
|
||||
};
|
||||
@@ -1,101 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import LanguageSwitcher from '@/components/LanguageSwitcher';
|
||||
import WelcomeMessage from '@/components/WelcomeMessage';
|
||||
import NavigationMenu from '@/components/NavigationMenu';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { LogOut } from 'lucide-react';
|
||||
|
||||
/**
|
||||
* Dashboard Page with Internationalization
|
||||
*
|
||||
* Note: Authentication protection is handled by (protected)/layout.tsx
|
||||
*/
|
||||
export default function Dashboard() {
|
||||
const t = useTranslations('common');
|
||||
const router = useRouter();
|
||||
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
// ✅ HttpOnly Cookie 방식: Next.js API Route로 프록시
|
||||
const response = await fetch('/api/auth/logout', {
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
console.log('✅ 로그아웃 완료: HttpOnly 쿠키 삭제됨');
|
||||
}
|
||||
|
||||
// 로그인 페이지로 리다이렉트
|
||||
router.push('/login');
|
||||
} catch (error) {
|
||||
console.error('로그아웃 처리 중 오류:', error);
|
||||
// 에러가 나도 로그인 페이지로 이동
|
||||
router.push('/login');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 p-8">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
{/* Header with Language Switcher */}
|
||||
<header className="flex justify-between items-center mb-8">
|
||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">
|
||||
{t('appName')}
|
||||
</h1>
|
||||
<div className="flex items-center gap-3">
|
||||
<LanguageSwitcher />
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleLogout}
|
||||
className="rounded-xl"
|
||||
>
|
||||
<LogOut className="w-4 h-4 mr-2" />
|
||||
Logout
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="space-y-8">
|
||||
{/* Welcome Section */}
|
||||
<WelcomeMessage />
|
||||
|
||||
{/* Navigation Menu */}
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold mb-4 text-gray-900 dark:text-white">
|
||||
{t('appName')} Modules
|
||||
</h2>
|
||||
<NavigationMenu />
|
||||
</div>
|
||||
|
||||
{/* Information Section */}
|
||||
<div className="bg-blue-50 dark:bg-blue-900/20 p-6 rounded-lg">
|
||||
<h3 className="text-lg font-semibold mb-2 text-blue-900 dark:text-blue-100">
|
||||
Multi-language Support
|
||||
</h3>
|
||||
<p className="text-blue-800 dark:text-blue-200">
|
||||
This ERP system supports Korean (한국어), English, and Japanese (日本語).
|
||||
Use the language switcher above to change the interface language.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Developer Info */}
|
||||
<div className="bg-white dark:bg-gray-800 p-6 rounded-lg shadow-md">
|
||||
<h3 className="text-lg font-semibold mb-4 text-gray-900 dark:text-white">
|
||||
For Developers
|
||||
</h3>
|
||||
<p className="text-gray-600 dark:text-gray-300 mb-2">
|
||||
Check the documentation in <code className="bg-gray-100 dark:bg-gray-700 px-2 py-1 rounded">claudedocs/i18n-usage-guide.md</code>
|
||||
</p>
|
||||
<p className="text-gray-600 dark:text-gray-300">
|
||||
Message files: <code className="bg-gray-100 dark:bg-gray-700 px-2 py-1 rounded">src/messages/</code>
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,63 +1,22 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 카드 수정 페이지 - 상세 페이지로 리다이렉트
|
||||
*
|
||||
* IntegratedDetailTemplate 통합으로 인해 [id]?mode=edit로 처리됨
|
||||
*/
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { useRouter, useParams } from 'next/navigation';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { CardForm } from '@/components/hr/CardManagement/CardForm';
|
||||
import { ContentLoadingSpinner } from '@/components/ui/loading-spinner';
|
||||
import { toast } from 'sonner';
|
||||
import type { Card, CardFormData } from '@/components/hr/CardManagement/types';
|
||||
import { getCard, updateCard } from '@/components/hr/CardManagement/actions';
|
||||
|
||||
export default function CardEditPage() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const [card, setCard] = useState<Card | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const loadCard = async () => {
|
||||
if (!params.id) return;
|
||||
// 상세 페이지의 edit 모드로 리다이렉트
|
||||
router.replace(`/ko/hr/card-management/${params.id}?mode=edit`);
|
||||
}, [router, params.id]);
|
||||
|
||||
setIsLoading(true);
|
||||
const result = await getCard(params.id as string);
|
||||
|
||||
if (result.success && result.data) {
|
||||
setCard(result.data);
|
||||
} else {
|
||||
toast.error(result.error || '카드 정보를 불러오는데 실패했습니다.');
|
||||
router.push('/ko/hr/card-management');
|
||||
}
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
loadCard();
|
||||
}, [params.id, router]);
|
||||
|
||||
const handleSubmit = async (data: CardFormData) => {
|
||||
if (!params.id) return;
|
||||
|
||||
setIsSaving(true);
|
||||
const result = await updateCard(params.id as string, data);
|
||||
|
||||
if (result.success) {
|
||||
toast.success('카드가 수정되었습니다.');
|
||||
router.push(`/ko/hr/card-management/${params.id}`);
|
||||
} else {
|
||||
toast.error(result.error || '수정에 실패했습니다.');
|
||||
}
|
||||
setIsSaving(false);
|
||||
};
|
||||
|
||||
if (isLoading || !card) {
|
||||
return <ContentLoadingSpinner text="카드 정보를 불러오는 중..." />;
|
||||
}
|
||||
|
||||
return (
|
||||
<CardForm
|
||||
mode="edit"
|
||||
card={card}
|
||||
onSubmit={handleSubmit}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,110 +1,88 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter, useParams } from 'next/navigation';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { CardDetail } from '@/components/hr/CardManagement/CardDetail';
|
||||
/**
|
||||
* 카드 상세/수정 페이지 - IntegratedDetailTemplate 적용
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useParams, useSearchParams } from 'next/navigation';
|
||||
import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate';
|
||||
import { cardConfig } from '@/components/hr/CardManagement/cardConfig';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { ContentLoadingSpinner } from '@/components/ui/loading-spinner';
|
||||
import { toast } from 'sonner';
|
||||
import type { Card } from '@/components/hr/CardManagement/types';
|
||||
import { getCard, deleteCard } from '@/components/hr/CardManagement/actions';
|
||||
getCard,
|
||||
updateCard,
|
||||
deleteCard,
|
||||
} from '@/components/hr/CardManagement/actions';
|
||||
import type { Card, CardFormData } from '@/components/hr/CardManagement/types';
|
||||
import type { DetailMode } from '@/components/templates/IntegratedDetailTemplate';
|
||||
|
||||
export default function CardDetailPage() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const searchParams = useSearchParams();
|
||||
const cardId = params.id as string;
|
||||
|
||||
const [card, setCard] = useState<Card | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// URL에서 mode 파라미터 확인 (?mode=edit)
|
||||
const urlMode = searchParams.get('mode');
|
||||
const initialMode: DetailMode = urlMode === 'edit' ? 'edit' : 'view';
|
||||
|
||||
// 데이터 로드
|
||||
useEffect(() => {
|
||||
const loadCard = async () => {
|
||||
if (!params.id) return;
|
||||
|
||||
async function loadCard() {
|
||||
setIsLoading(true);
|
||||
const result = await getCard(params.id as string);
|
||||
|
||||
if (result.success && result.data) {
|
||||
setCard(result.data);
|
||||
} else {
|
||||
toast.error(result.error || '카드 정보를 불러오는데 실패했습니다.');
|
||||
router.push('/ko/hr/card-management');
|
||||
try {
|
||||
const result = await getCard(cardId);
|
||||
if (result.success && result.data) {
|
||||
setCard(result.data);
|
||||
} else {
|
||||
setError(result.error || '카드를 찾을 수 없습니다.');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load card:', err);
|
||||
setError('카드 조회 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
setIsLoading(false);
|
||||
};
|
||||
}
|
||||
|
||||
loadCard();
|
||||
}, [params.id, router]);
|
||||
}, [cardId]);
|
||||
|
||||
const handleEdit = () => {
|
||||
router.push(`/ko/hr/card-management/${params.id}/edit`);
|
||||
// 수정 핸들러
|
||||
const handleSubmit = async (data: Record<string, unknown>) => {
|
||||
const result = await updateCard(cardId, data as Partial<CardFormData>);
|
||||
return { success: result.success, error: result.error };
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
setDeleteDialogOpen(true);
|
||||
// 삭제 핸들러
|
||||
const handleDelete = async () => {
|
||||
const result = await deleteCard(cardId);
|
||||
return { success: result.success, error: result.error };
|
||||
};
|
||||
|
||||
const confirmDelete = async () => {
|
||||
if (!params.id) return;
|
||||
|
||||
setIsDeleting(true);
|
||||
const result = await deleteCard(params.id as string);
|
||||
|
||||
if (result.success) {
|
||||
toast.success('카드가 삭제되었습니다.');
|
||||
router.push('/ko/hr/card-management');
|
||||
} else {
|
||||
toast.error(result.error || '삭제에 실패했습니다.');
|
||||
setIsDeleting(false);
|
||||
setDeleteDialogOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading || !card) {
|
||||
return <ContentLoadingSpinner text="카드 정보를 불러오는 중..." />;
|
||||
// 에러 상태
|
||||
if (error && !isLoading) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
{error}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<CardDetail
|
||||
card={card}
|
||||
onEdit={handleEdit}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
|
||||
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>카드 삭제</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
"{card.cardName}" 카드를 삭제하시겠습니까?
|
||||
<br />
|
||||
<span className="text-destructive">
|
||||
삭제된 카드 정보는 복구할 수 없습니다.
|
||||
</span>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isDeleting}>취소</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={confirmDelete}
|
||||
disabled={isDeleting}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
{isDeleting ? '삭제 중...' : '삭제'}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
<IntegratedDetailTemplate
|
||||
config={cardConfig}
|
||||
mode={initialMode}
|
||||
initialData={card || undefined}
|
||||
itemId={cardId}
|
||||
isLoading={isLoading}
|
||||
onSubmit={handleSubmit}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
58
src/app/[locale]/(protected)/hr/card-management/loading.tsx
Normal file
58
src/app/[locale]/(protected)/hr/card-management/loading.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
|
||||
/**
|
||||
* 카드관리 페이지 로딩 UI (Skeleton)
|
||||
*
|
||||
* 페이지 전환 시 스피너 대신 Skeleton으로 일관된 UX 제공
|
||||
*/
|
||||
export default function CardManagementLoading() {
|
||||
return (
|
||||
<div className="p-3 md:p-6 space-y-6">
|
||||
{/* 헤더 Skeleton */}
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-8 w-32" />
|
||||
<Skeleton className="h-4 w-48" />
|
||||
</div>
|
||||
|
||||
{/* 기본 정보 카드 Skeleton */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Skeleton className="h-5 w-24" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{[1, 2, 3, 4, 5, 6].map((i) => (
|
||||
<div key={i} className="space-y-2">
|
||||
<Skeleton className="h-4 w-20" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 사용자 정보 카드 Skeleton */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Skeleton className="h-5 w-28" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-4 w-32" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 버튼 영역 Skeleton */}
|
||||
<div className="flex items-center justify-between">
|
||||
<Skeleton className="h-10 w-24" />
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="h-10 w-20" />
|
||||
<Skeleton className="h-10 w-20" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,33 +1,25 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useState } from 'react';
|
||||
import { CardForm } from '@/components/hr/CardManagement/CardForm';
|
||||
import { toast } from 'sonner';
|
||||
import type { CardFormData } from '@/components/hr/CardManagement/types';
|
||||
/**
|
||||
* 카드 등록 페이지 - IntegratedDetailTemplate 적용
|
||||
*/
|
||||
|
||||
import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate';
|
||||
import { cardConfig } from '@/components/hr/CardManagement/cardConfig';
|
||||
import { createCard } from '@/components/hr/CardManagement/actions';
|
||||
import type { CardFormData } from '@/components/hr/CardManagement/types';
|
||||
|
||||
export default function CardNewPage() {
|
||||
const router = useRouter();
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
const handleSubmit = async (data: CardFormData) => {
|
||||
setIsSaving(true);
|
||||
const result = await createCard(data);
|
||||
|
||||
if (result.success) {
|
||||
toast.success('카드가 등록되었습니다.');
|
||||
router.push('/ko/hr/card-management');
|
||||
} else {
|
||||
toast.error(result.error || '등록에 실패했습니다.');
|
||||
}
|
||||
setIsSaving(false);
|
||||
export default function NewCardPage() {
|
||||
const handleSubmit = async (data: Record<string, unknown>) => {
|
||||
const result = await createCard(data as CardFormData);
|
||||
return { success: result.success, error: result.error };
|
||||
};
|
||||
|
||||
return (
|
||||
<CardForm
|
||||
<IntegratedDetailTemplate
|
||||
config={cardConfig}
|
||||
mode="create"
|
||||
onSubmit={handleSubmit}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,70 +1,23 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { useRouter, useParams } from 'next/navigation';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { EmployeeForm } from '@/components/hr/EmployeeManagement/EmployeeForm';
|
||||
import { getEmployeeById, updateEmployee } from '@/components/hr/EmployeeManagement/actions';
|
||||
import { ContentLoadingSpinner } from '@/components/ui/loading-spinner';
|
||||
import type { Employee, EmployeeFormData } from '@/components/hr/EmployeeManagement/types';
|
||||
|
||||
/**
|
||||
* V2 하위 호환성: /[id]/edit → /[id]?mode=edit 리다이렉트
|
||||
*/
|
||||
export default function EmployeeEditPage() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const [employee, setEmployee] = useState<Employee | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
// 직원 데이터 조회
|
||||
const fetchEmployee = useCallback(async () => {
|
||||
const id = params.id as string;
|
||||
if (!id) return;
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const data = await getEmployeeById(id);
|
||||
// __authError 처리
|
||||
if (data && '__authError' in data) {
|
||||
router.push('/ko/login');
|
||||
return;
|
||||
}
|
||||
setEmployee(data);
|
||||
} catch (error) {
|
||||
console.error('[EmployeeEditPage] fetchEmployee error:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [params.id, router]);
|
||||
const id = params.id as string;
|
||||
|
||||
useEffect(() => {
|
||||
fetchEmployee();
|
||||
}, [fetchEmployee]);
|
||||
router.replace(`/ko/hr/employee-management/${id}?mode=edit`);
|
||||
}, [id, router]);
|
||||
|
||||
const handleSave = async (data: EmployeeFormData) => {
|
||||
const id = params.id as string;
|
||||
if (!id) return;
|
||||
|
||||
try {
|
||||
const result = await updateEmployee(id, data);
|
||||
if (result.success) {
|
||||
router.push(`/ko/hr/employee-management/${id}`);
|
||||
} else {
|
||||
console.error('[EmployeeEditPage] Update failed:', result.error);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[EmployeeEditPage] Update error:', error);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return <ContentLoadingSpinner text="사원 정보를 불러오는 중..." />;
|
||||
}
|
||||
|
||||
if (!employee) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<p className="text-muted-foreground">사원 정보를 찾을 수 없습니다.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <EmployeeForm mode="edit" employee={employee} onSave={handleSave} />;
|
||||
}
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-muted-foreground">리다이렉트 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter, useParams } from 'next/navigation';
|
||||
import { useRouter, useParams, useSearchParams } from 'next/navigation';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { EmployeeForm } from '@/components/hr/EmployeeManagement/EmployeeForm';
|
||||
import { getEmployeeById, deleteEmployee } from '@/components/hr/EmployeeManagement/actions';
|
||||
import { getEmployeeById, deleteEmployee, updateEmployee } from '@/components/hr/EmployeeManagement/actions';
|
||||
import { ServerErrorPage } from '@/components/common/ServerErrorPage';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
@@ -15,11 +16,13 @@ import {
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { ContentLoadingSpinner } from '@/components/ui/loading-spinner';
|
||||
import type { Employee } from '@/components/hr/EmployeeManagement/types';
|
||||
import type { Employee, EmployeeFormData } from '@/components/hr/EmployeeManagement/types';
|
||||
|
||||
export default function EmployeeDetailPage() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const searchParams = useSearchParams();
|
||||
const mode = searchParams.get('mode') === 'edit' ? 'edit' : 'view';
|
||||
const [employee, setEmployee] = useState<Employee | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
@@ -51,7 +54,23 @@ export default function EmployeeDetailPage() {
|
||||
}, [fetchEmployee]);
|
||||
|
||||
const handleEdit = () => {
|
||||
router.push(`/ko/hr/employee-management/${params.id}/edit`);
|
||||
router.push(`/ko/hr/employee-management/${params.id}?mode=edit`);
|
||||
};
|
||||
|
||||
const handleSave = async (data: EmployeeFormData) => {
|
||||
const id = params.id as string;
|
||||
if (!id) return;
|
||||
|
||||
try {
|
||||
const result = await updateEmployee(id, data);
|
||||
if (result.success) {
|
||||
router.push(`/ko/hr/employee-management/${id}`);
|
||||
} else {
|
||||
console.error('[EmployeeDetailPage] Update failed:', result.error);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[EmployeeDetailPage] Update error:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
@@ -84,19 +103,24 @@ export default function EmployeeDetailPage() {
|
||||
|
||||
if (!employee) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<p className="text-muted-foreground">사원 정보를 찾을 수 없습니다.</p>
|
||||
</div>
|
||||
<ServerErrorPage
|
||||
title="사원 정보를 불러올 수 없습니다"
|
||||
message="사원 정보를 찾을 수 없습니다."
|
||||
showBackButton={true}
|
||||
showHomeButton={true}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<EmployeeForm
|
||||
mode="view"
|
||||
mode={mode}
|
||||
employee={employee}
|
||||
onEdit={handleEdit}
|
||||
onDelete={handleDelete}
|
||||
{...(mode === 'edit'
|
||||
? { onSave: handleSave }
|
||||
: { onEdit: handleEdit, onDelete: handleDelete }
|
||||
)}
|
||||
/>
|
||||
|
||||
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
/**
|
||||
* 품목 등록 페이지
|
||||
*
|
||||
* DynamicItemForm을 사용하여 품목기준관리 데이터 기반 동적 폼 렌더링
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import DynamicItemForm from '@/components/items/DynamicItemForm';
|
||||
import type { DynamicFormData } from '@/components/items/DynamicItemForm/types';
|
||||
// 2025-12-16: options 관련 변환 로직 제거
|
||||
// 백엔드가 품목기준관리 field_key 매핑을 처리하므로 프론트에서 변환 불필요
|
||||
import { DuplicateCodeError } from '@/lib/api/error-handler';
|
||||
|
||||
// 기존 ItemForm (주석처리 - 롤백 시 사용)
|
||||
// import ItemForm from '@/components/items/ItemForm';
|
||||
// import type { CreateItemFormData } from '@/lib/utils/validation';
|
||||
|
||||
export default function CreateItemPage() {
|
||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||
|
||||
const handleSubmit = async (data: DynamicFormData) => {
|
||||
setSubmitError(null);
|
||||
|
||||
// 필드명 변환: spec → specification (백엔드 API 규격)
|
||||
const submitData = { ...data };
|
||||
if (submitData.spec !== undefined) {
|
||||
submitData.specification = submitData.spec;
|
||||
delete submitData.spec;
|
||||
}
|
||||
|
||||
// 2025-12-15: item_type은 Request Body에서 필수 (ItemService.store validation)
|
||||
// product_type과 item_type을 동일하게 설정
|
||||
const itemType = submitData.product_type as string;
|
||||
submitData.item_type = itemType;
|
||||
|
||||
// API 호출 전 이미지 데이터 제거 (파일 업로드는 별도 API 사용)
|
||||
// bending_diagram이 base64 데이터인 경우 제거 (JSON에 포함시키면 안됨)
|
||||
if (submitData.bending_diagram && typeof submitData.bending_diagram === 'string' && (submitData.bending_diagram as string).startsWith('data:')) {
|
||||
delete submitData.bending_diagram;
|
||||
}
|
||||
// 시방서/인정서 파일 필드도 base64면 제거
|
||||
if (submitData.specification_file && typeof submitData.specification_file === 'string' && (submitData.specification_file as string).startsWith('data:')) {
|
||||
delete submitData.specification_file;
|
||||
}
|
||||
if (submitData.certification_file && typeof submitData.certification_file === 'string' && (submitData.certification_file as string).startsWith('data:')) {
|
||||
delete submitData.certification_file;
|
||||
}
|
||||
|
||||
// API 호출: POST /api/proxy/items
|
||||
// 백엔드에서 product_type에 따라 Product/Material 분기 처리
|
||||
const response = await fetch('/api/proxy/items', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(submitData),
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!response.ok || !result.success) {
|
||||
// 2025-12-11: 백엔드 중복 에러 처리 (DuplicateCodeException)
|
||||
// duplicate_id가 있으면 DuplicateCodeError throw → DynamicItemForm에서 다이얼로그 표시
|
||||
if (response.status === 400 && result.duplicate_id) {
|
||||
console.warn('[CreateItemPage] 품목코드 중복 에러:', result);
|
||||
throw new DuplicateCodeError(
|
||||
result.message || '해당 품목코드가 이미 존재합니다.',
|
||||
result.duplicate_id,
|
||||
result.duplicate_code
|
||||
);
|
||||
}
|
||||
|
||||
const errorMessage = result.message || '품목 등록에 실패했습니다.';
|
||||
console.error('[CreateItemPage] 품목 등록 실패:', errorMessage);
|
||||
setSubmitError(errorMessage);
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
// 성공 시 DynamicItemForm 내부에서 /items로 리다이렉트 처리됨
|
||||
// console.log('[CreateItemPage] 품목 등록 성공:', result.data);
|
||||
|
||||
// 생성된 품목 ID를 포함한 데이터 반환 (파일 업로드용)
|
||||
return { id: result.data.id, ...result.data };
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
{submitError && (
|
||||
<div className="mb-4 p-4 bg-red-50 border border-red-200 rounded-lg text-red-900">
|
||||
⚠️ {submitError}
|
||||
</div>
|
||||
)}
|
||||
<DynamicItemForm
|
||||
mode="create"
|
||||
onSubmit={handleSubmit}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
/**
|
||||
* 품목 관리 페이지
|
||||
*
|
||||
* 품목기준관리 API 연동
|
||||
* - 품목 목록: API에서 조회
|
||||
* - 테이블 컬럼: custom-tabs API에서 동적 구성
|
||||
*/
|
||||
|
||||
import ItemListClient from '@/components/items/ItemListClient';
|
||||
|
||||
/**
|
||||
* 품목 목록 페이지
|
||||
*/
|
||||
export default function ItemsPage() {
|
||||
return <ItemListClient />;
|
||||
}
|
||||
|
||||
/**
|
||||
* 메타데이터 설정
|
||||
*/
|
||||
export const metadata = {
|
||||
title: '품목 관리',
|
||||
description: '품목 목록 조회 및 관리',
|
||||
};
|
||||
@@ -8,7 +8,10 @@ import { PageLoadingSpinner } from '@/components/ui/loading-spinner';
|
||||
* - React Suspense 자동 적용
|
||||
* - 페이지 전환 시 즉각적인 피드백
|
||||
* - 공통 레이아웃 스타일로 통일 (min-h-[calc(100vh-200px)])
|
||||
*
|
||||
* Note: 특정 경로에서 Skeleton UI를 사용하려면 해당 경로에
|
||||
* 별도의 loading.tsx를 생성하세요. (예: settings/accounts/loading.tsx)
|
||||
*/
|
||||
export default function ProtectedLoading() {
|
||||
return <PageLoadingSpinner />;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Process | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(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 (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-muted-foreground">공정 정보를 불러오는 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !data) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[400px] gap-4">
|
||||
<div className="text-muted-foreground">{error || '공정을 찾을 수 없습니다.'}</div>
|
||||
<button
|
||||
onClick={() => router.back()}
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
뒤로 가기
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <ProcessForm mode="edit" initialData={data} />;
|
||||
}
|
||||
return <ContentLoadingSpinner text="페이지 이동 중..." />;
|
||||
}
|
||||
|
||||
@@ -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<Process | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(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 (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-muted-foreground">공정 정보를 불러오는 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !data) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[400px] gap-4">
|
||||
<div className="text-muted-foreground">{error || '공정을 찾을 수 없습니다.'}</div>
|
||||
<button
|
||||
onClick={() => router.back()}
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
뒤로 가기
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <ProcessDetail process={data} />;
|
||||
}
|
||||
return <ProcessDetailClientV2 processId={id} />;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
/**
|
||||
* 공정 등록 페이지
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { ProcessForm } from '@/components/process-management';
|
||||
/**
|
||||
* 공정관리 등록 페이지
|
||||
*
|
||||
* IntegratedDetailTemplate V2 마이그레이션
|
||||
*/
|
||||
|
||||
export default function CreateProcessPage() {
|
||||
return <ProcessForm mode="create" />;
|
||||
}
|
||||
import { ProcessDetailClientV2 } from '@/components/process-management';
|
||||
|
||||
export default function ProcessCreatePage() {
|
||||
return <ProcessDetailClientV2 processId="new" />;
|
||||
}
|
||||
|
||||
@@ -1,18 +1,26 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 출하관리 - 수정 페이지 (Client Component)
|
||||
* URL: /outbound/shipments/[id]/edit
|
||||
*/
|
||||
|
||||
import { use } from 'react';
|
||||
import { ShipmentEdit } from '@/components/outbound/ShipmentManagement';
|
||||
import { use, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
interface ShipmentEditPageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* V2 하위 호환성: /[id]/edit → /[id]?mode=edit 리다이렉트
|
||||
*/
|
||||
export default function ShipmentEditPage({ params }: ShipmentEditPageProps) {
|
||||
const { id } = use(params);
|
||||
return <ShipmentEdit id={id} />;
|
||||
}
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
router.replace(`/outbound/shipments/${id}?mode=edit`);
|
||||
}, [id, router]);
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-muted-foreground">리다이렉트 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,10 +3,12 @@
|
||||
/**
|
||||
* 출하관리 - 상세 페이지 (Client Component)
|
||||
* URL: /outbound/shipments/[id]
|
||||
* V2 패턴: ?mode=edit로 수정 모드 전환
|
||||
*/
|
||||
|
||||
import { use } from 'react';
|
||||
import { ShipmentDetail } from '@/components/outbound/ShipmentManagement';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { ShipmentDetail, ShipmentEdit } from '@/components/outbound/ShipmentManagement';
|
||||
|
||||
interface ShipmentDetailPageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
@@ -14,5 +16,13 @@ interface ShipmentDetailPageProps {
|
||||
|
||||
export default function ShipmentDetailPage({ params }: ShipmentDetailPageProps) {
|
||||
const { id } = use(params);
|
||||
const searchParams = useSearchParams();
|
||||
const mode = searchParams.get('mode');
|
||||
const isEditMode = mode === 'edit';
|
||||
|
||||
if (isEditMode) {
|
||||
return <ShipmentEdit id={id} />;
|
||||
}
|
||||
|
||||
return <ShipmentDetail id={id} />;
|
||||
}
|
||||
@@ -1,205 +1,32 @@
|
||||
/**
|
||||
* 품목 수정 페이지
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import { ContentLoadingSpinner } from '@/components/ui/loading-spinner';
|
||||
import ItemForm from '@/components/items/ItemForm';
|
||||
import type { ItemMaster } from '@/types/item';
|
||||
import type { CreateItemFormData } from '@/lib/utils/validation';
|
||||
import { use, useEffect } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
|
||||
// Mock 데이터 (API 연동 전 임시)
|
||||
const mockItems: ItemMaster[] = [
|
||||
{
|
||||
id: '1',
|
||||
itemCode: 'KD-FG-001',
|
||||
itemName: '스크린 제품 A',
|
||||
itemType: 'FG',
|
||||
unit: 'EA',
|
||||
specification: '2000x2000',
|
||||
isActive: true,
|
||||
category1: '본체부품',
|
||||
category2: '가이드시스템',
|
||||
salesPrice: 150000,
|
||||
purchasePrice: 100000,
|
||||
marginRate: 33.3,
|
||||
processingCost: 20000,
|
||||
laborCost: 15000,
|
||||
installCost: 10000,
|
||||
productCategory: 'SCREEN',
|
||||
lotAbbreviation: 'KD',
|
||||
note: '스크린 제품 샘플입니다.',
|
||||
safetyStock: 10,
|
||||
leadTime: 7,
|
||||
currentRevision: 0,
|
||||
isFinal: false,
|
||||
createdAt: '2025-01-10T00:00:00Z',
|
||||
updatedAt: '2025-01-12T00:00:00Z',
|
||||
bom: [
|
||||
{
|
||||
id: 'bom-1',
|
||||
childItemCode: 'KD-PT-001',
|
||||
childItemName: '가이드레일(벽면형)',
|
||||
quantity: 2,
|
||||
unit: 'EA',
|
||||
unitPrice: 35000,
|
||||
quantityFormula: 'H / 1000',
|
||||
},
|
||||
{
|
||||
id: 'bom-2',
|
||||
childItemCode: 'KD-PT-002',
|
||||
childItemName: '절곡품 샘플',
|
||||
quantity: 4,
|
||||
unit: 'EA',
|
||||
unitPrice: 30000,
|
||||
isBending: true,
|
||||
},
|
||||
{
|
||||
id: 'bom-3',
|
||||
childItemCode: 'KD-SM-001',
|
||||
childItemName: '볼트 M6x20',
|
||||
quantity: 20,
|
||||
unit: 'EA',
|
||||
unitPrice: 50,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
itemCode: 'KD-PT-001',
|
||||
itemName: '가이드레일(벽면형)',
|
||||
itemType: 'PT',
|
||||
unit: 'EA',
|
||||
specification: '2438mm',
|
||||
isActive: true,
|
||||
category1: '본체부품',
|
||||
category2: '가이드시스템',
|
||||
category3: '가이드레일',
|
||||
salesPrice: 50000,
|
||||
purchasePrice: 35000,
|
||||
marginRate: 30,
|
||||
partType: 'ASSEMBLY',
|
||||
partUsage: 'GUIDE_RAIL',
|
||||
installationType: '벽면형',
|
||||
assemblyType: 'M',
|
||||
assemblyLength: '2438',
|
||||
currentRevision: 0,
|
||||
isFinal: false,
|
||||
createdAt: '2025-01-10T00:00:00Z',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
itemCode: 'KD-PT-002',
|
||||
itemName: '절곡품 샘플',
|
||||
itemType: 'PT',
|
||||
unit: 'EA',
|
||||
specification: 'EGI 1.55T',
|
||||
isActive: true,
|
||||
partType: 'BENDING',
|
||||
material: 'EGI 1.55T',
|
||||
length: '2000',
|
||||
salesPrice: 30000,
|
||||
currentRevision: 0,
|
||||
isFinal: false,
|
||||
createdAt: '2025-01-10T00:00:00Z',
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
itemCode: 'KD-RM-001',
|
||||
itemName: 'SPHC-SD',
|
||||
itemType: 'RM',
|
||||
unit: 'KG',
|
||||
specification: '1.6T x 1219 x 2438',
|
||||
isActive: true,
|
||||
category1: '철강재',
|
||||
purchasePrice: 1500,
|
||||
material: 'SPHC-SD',
|
||||
currentRevision: 0,
|
||||
isFinal: false,
|
||||
createdAt: '2025-01-10T00:00:00Z',
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
itemCode: 'KD-SM-001',
|
||||
itemName: '볼트 M6x20',
|
||||
itemType: 'SM',
|
||||
unit: 'EA',
|
||||
specification: 'M6x20',
|
||||
isActive: true,
|
||||
category1: '구조재/부속품',
|
||||
category2: '볼트/너트',
|
||||
purchasePrice: 50,
|
||||
currentRevision: 0,
|
||||
isFinal: false,
|
||||
createdAt: '2025-01-10T00:00:00Z',
|
||||
},
|
||||
];
|
||||
interface PageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export default function EditItemPage() {
|
||||
const params = useParams();
|
||||
/**
|
||||
* V2 하위 호환성: /[id]/edit → /[id]?mode=edit 리다이렉트
|
||||
* 기존 쿼리 파라미터(type, id)는 유지
|
||||
*/
|
||||
export default function ItemEditPage({ params }: PageProps) {
|
||||
const { id } = use(params);
|
||||
const router = useRouter();
|
||||
const [item, setItem] = useState<ItemMaster | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
useEffect(() => {
|
||||
// TODO: API 연동 시 fetchItemByCode() 호출
|
||||
const fetchItem = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
// params.id 타입 체크
|
||||
if (!params.id || typeof params.id !== 'string') {
|
||||
alert('잘못된 품목 ID입니다.');
|
||||
router.push('/items');
|
||||
return;
|
||||
}
|
||||
// 기존 쿼리 파라미터 유지하면서 mode=edit 추가
|
||||
const type = searchParams.get('type') || 'FG';
|
||||
const itemId = searchParams.get('id') || '';
|
||||
|
||||
// Mock: 데이터 조회
|
||||
const itemCode = decodeURIComponent(params.id);
|
||||
const foundItem = mockItems.find((item) => item.itemCode === itemCode);
|
||||
|
||||
if (foundItem) {
|
||||
setItem(foundItem);
|
||||
} else {
|
||||
alert('품목을 찾을 수 없습니다.');
|
||||
router.push('/items');
|
||||
}
|
||||
} catch {
|
||||
alert('품목 조회에 실패했습니다.');
|
||||
router.push('/items');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchItem();
|
||||
}, [params.id, router]);
|
||||
|
||||
const handleSubmit = async (data: CreateItemFormData) => {
|
||||
// TODO: API 연동 시 updateItem() 호출
|
||||
console.log('품목 수정 데이터:', data);
|
||||
|
||||
// Mock: 성공 메시지
|
||||
alert(`품목 "${data.itemName}" (${data.itemCode})이(가) 수정되었습니다.`);
|
||||
|
||||
// API 연동 예시:
|
||||
// const updatedItem = await updateItem(item.itemCode, data);
|
||||
// router.push(`/items/${updatedItem.itemCode}`);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return <ContentLoadingSpinner text="품목 정보를 불러오는 중..." />;
|
||||
}
|
||||
|
||||
if (!item) {
|
||||
return null;
|
||||
}
|
||||
router.replace(`/production/screen-production/${id}?type=${type}&id=${itemId}&mode=edit`);
|
||||
}, [id, router, searchParams]);
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<ItemForm mode="edit" initialData={item} onSubmit={handleSubmit} />
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-muted-foreground">리다이렉트 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,185 +1,34 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 품목 상세 조회 페이지 (Client Component)
|
||||
* 품목 상세/수정 페이지 (Client Component)
|
||||
* V2 패턴: ?mode=edit로 수정 모드 전환
|
||||
*/
|
||||
|
||||
import { use, useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { ContentLoadingSpinner } from '@/components/ui/loading-spinner';
|
||||
import ItemDetailClient from '@/components/items/ItemDetailClient';
|
||||
import type { ItemMaster } from '@/types/item';
|
||||
import { use } from 'react';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { ItemDetailView } from '@/components/items/ItemDetailView';
|
||||
import { ItemDetailEdit } from '@/components/items/ItemDetailEdit';
|
||||
|
||||
// Mock 데이터 (API 연동 전 임시)
|
||||
const mockItems: ItemMaster[] = [
|
||||
{
|
||||
id: '1',
|
||||
itemCode: 'KD-FG-001',
|
||||
itemName: '스크린 제품 A',
|
||||
itemType: 'FG',
|
||||
unit: 'EA',
|
||||
specification: '2000x2000',
|
||||
isActive: true,
|
||||
category1: '본체부품',
|
||||
category2: '가이드시스템',
|
||||
salesPrice: 150000,
|
||||
purchasePrice: 100000,
|
||||
marginRate: 33.3,
|
||||
processingCost: 20000,
|
||||
laborCost: 15000,
|
||||
installCost: 10000,
|
||||
productCategory: 'SCREEN',
|
||||
lotAbbreviation: 'KD',
|
||||
note: '스크린 제품 샘플입니다.',
|
||||
safetyStock: 10,
|
||||
leadTime: 7,
|
||||
currentRevision: 0,
|
||||
isFinal: false,
|
||||
createdAt: '2025-01-10T00:00:00Z',
|
||||
updatedAt: '2025-01-12T00:00:00Z',
|
||||
bom: [
|
||||
{
|
||||
id: 'bom-1',
|
||||
childItemCode: 'KD-PT-001',
|
||||
childItemName: '가이드레일(벽면형)',
|
||||
quantity: 2,
|
||||
unit: 'EA',
|
||||
unitPrice: 35000,
|
||||
quantityFormula: 'H / 1000',
|
||||
},
|
||||
{
|
||||
id: 'bom-2',
|
||||
childItemCode: 'KD-PT-002',
|
||||
childItemName: '절곡품 샘플',
|
||||
quantity: 4,
|
||||
unit: 'EA',
|
||||
unitPrice: 30000,
|
||||
isBending: true,
|
||||
},
|
||||
{
|
||||
id: 'bom-3',
|
||||
childItemCode: 'KD-SM-001',
|
||||
childItemName: '볼트 M6x20',
|
||||
quantity: 20,
|
||||
unit: 'EA',
|
||||
unitPrice: 50,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
itemCode: 'KD-PT-001',
|
||||
itemName: '가이드레일(벽면형)',
|
||||
itemType: 'PT',
|
||||
unit: 'EA',
|
||||
specification: '2438mm',
|
||||
isActive: true,
|
||||
category1: '본체부품',
|
||||
category2: '가이드시스템',
|
||||
category3: '가이드레일',
|
||||
salesPrice: 50000,
|
||||
purchasePrice: 35000,
|
||||
marginRate: 30,
|
||||
partType: 'ASSEMBLY',
|
||||
partUsage: 'GUIDE_RAIL',
|
||||
installationType: '벽면형',
|
||||
assemblyType: 'M',
|
||||
assemblyLength: '2438',
|
||||
currentRevision: 0,
|
||||
isFinal: false,
|
||||
createdAt: '2025-01-10T00:00:00Z',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
itemCode: 'KD-PT-002',
|
||||
itemName: '절곡품 샘플',
|
||||
itemType: 'PT',
|
||||
unit: 'EA',
|
||||
specification: 'EGI 1.55T',
|
||||
isActive: true,
|
||||
partType: 'BENDING',
|
||||
material: 'EGI 1.55T',
|
||||
length: '2000',
|
||||
salesPrice: 30000,
|
||||
currentRevision: 0,
|
||||
isFinal: false,
|
||||
createdAt: '2025-01-10T00:00:00Z',
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
itemCode: 'KD-RM-001',
|
||||
itemName: 'SPHC-SD',
|
||||
itemType: 'RM',
|
||||
unit: 'KG',
|
||||
specification: '1.6T x 1219 x 2438',
|
||||
isActive: true,
|
||||
category1: '철강재',
|
||||
purchasePrice: 1500,
|
||||
material: 'SPHC-SD',
|
||||
currentRevision: 0,
|
||||
isFinal: false,
|
||||
createdAt: '2025-01-10T00:00:00Z',
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
itemCode: 'KD-SM-001',
|
||||
itemName: '볼트 M6x20',
|
||||
itemType: 'SM',
|
||||
unit: 'EA',
|
||||
specification: 'M6x20',
|
||||
isActive: true,
|
||||
category1: '구조재/부속품',
|
||||
category2: '볼트/너트',
|
||||
purchasePrice: 50,
|
||||
currentRevision: 0,
|
||||
isFinal: false,
|
||||
createdAt: '2025-01-10T00:00:00Z',
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* 품목 상세 페이지
|
||||
*/
|
||||
export default function ItemDetailPage({
|
||||
params,
|
||||
}: {
|
||||
interface PageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
}
|
||||
|
||||
export default function ItemDetailPage({ params }: PageProps) {
|
||||
const { id } = use(params);
|
||||
const router = useRouter();
|
||||
const [item, setItem] = useState<ItemMaster | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
useEffect(() => {
|
||||
// API 연동 전 mock 데이터 사용
|
||||
const foundItem = mockItems.find(
|
||||
(item) => item.itemCode === decodeURIComponent(id)
|
||||
);
|
||||
setItem(foundItem || null);
|
||||
setIsLoading(false);
|
||||
}, [id]);
|
||||
// URL에서 type, id, mode 쿼리 파라미터 읽기
|
||||
const itemType = searchParams.get('type') || 'FG';
|
||||
const itemId = searchParams.get('id') || '';
|
||||
const mode = searchParams.get('mode') === 'edit' ? 'edit' : 'view';
|
||||
|
||||
if (isLoading) {
|
||||
return <ContentLoadingSpinner text="품목 정보를 불러오는 중..." />;
|
||||
// 품목 코드 디코딩
|
||||
const itemCode = decodeURIComponent(id);
|
||||
|
||||
if (mode === 'edit') {
|
||||
return <ItemDetailEdit itemCode={itemCode} itemType={itemType} itemId={itemId} />;
|
||||
}
|
||||
|
||||
if (!item) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[400px] gap-4">
|
||||
<div className="text-muted-foreground">품목을 찾을 수 없습니다.</div>
|
||||
<button
|
||||
onClick={() => router.back()}
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
뒤로 가기
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<ItemDetailClient item={item} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return <ItemDetailView itemCode={itemCode} itemType={itemType} itemId={itemId} />;
|
||||
}
|
||||
|
||||
@@ -1,28 +1,101 @@
|
||||
/**
|
||||
* 품목 등록 페이지
|
||||
*
|
||||
* DynamicItemForm을 사용하여 품목기준관리 데이터 기반 동적 폼 렌더링
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import ItemForm from '@/components/items/ItemForm';
|
||||
import type { CreateItemFormData } from '@/lib/utils/validation';
|
||||
import { useState } from 'react';
|
||||
import DynamicItemForm from '@/components/items/DynamicItemForm';
|
||||
import type { DynamicFormData } from '@/components/items/DynamicItemForm/types';
|
||||
// 2025-12-16: options 관련 변환 로직 제거
|
||||
// 백엔드가 품목기준관리 field_key 매핑을 처리하므로 프론트에서 변환 불필요
|
||||
import { DuplicateCodeError } from '@/lib/api/error-handler';
|
||||
|
||||
// 기존 ItemForm (주석처리 - 롤백 시 사용)
|
||||
// import ItemForm from '@/components/items/ItemForm';
|
||||
// import type { CreateItemFormData } from '@/lib/utils/validation';
|
||||
|
||||
export default function CreateItemPage() {
|
||||
const handleSubmit = async (data: CreateItemFormData) => {
|
||||
// TODO: API 연동 시 createItem() 호출
|
||||
console.log('품목 등록 데이터:', data);
|
||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||
|
||||
// Mock: 성공 메시지
|
||||
alert(`품목 "${data.itemName}" (${data.itemCode})이(가) 등록되었습니다.`);
|
||||
const handleSubmit = async (data: DynamicFormData) => {
|
||||
setSubmitError(null);
|
||||
|
||||
// API 연동 예시:
|
||||
// const newItem = await createItem(data);
|
||||
// router.push(`/items/${newItem.itemCode}`);
|
||||
// 필드명 변환: spec → specification (백엔드 API 규격)
|
||||
const submitData = { ...data };
|
||||
if (submitData.spec !== undefined) {
|
||||
submitData.specification = submitData.spec;
|
||||
delete submitData.spec;
|
||||
}
|
||||
|
||||
// 2025-12-15: item_type은 Request Body에서 필수 (ItemService.store validation)
|
||||
// product_type과 item_type을 동일하게 설정
|
||||
const itemType = submitData.product_type as string;
|
||||
submitData.item_type = itemType;
|
||||
|
||||
// API 호출 전 이미지 데이터 제거 (파일 업로드는 별도 API 사용)
|
||||
// bending_diagram이 base64 데이터인 경우 제거 (JSON에 포함시키면 안됨)
|
||||
if (submitData.bending_diagram && typeof submitData.bending_diagram === 'string' && (submitData.bending_diagram as string).startsWith('data:')) {
|
||||
delete submitData.bending_diagram;
|
||||
}
|
||||
// 시방서/인정서 파일 필드도 base64면 제거
|
||||
if (submitData.specification_file && typeof submitData.specification_file === 'string' && (submitData.specification_file as string).startsWith('data:')) {
|
||||
delete submitData.specification_file;
|
||||
}
|
||||
if (submitData.certification_file && typeof submitData.certification_file === 'string' && (submitData.certification_file as string).startsWith('data:')) {
|
||||
delete submitData.certification_file;
|
||||
}
|
||||
|
||||
// API 호출: POST /api/proxy/items
|
||||
// 백엔드에서 product_type에 따라 Product/Material 분기 처리
|
||||
const response = await fetch('/api/proxy/items', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(submitData),
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!response.ok || !result.success) {
|
||||
// 2025-12-11: 백엔드 중복 에러 처리 (DuplicateCodeException)
|
||||
// duplicate_id가 있으면 DuplicateCodeError throw → DynamicItemForm에서 다이얼로그 표시
|
||||
if (response.status === 400 && result.duplicate_id) {
|
||||
console.warn('[CreateItemPage] 품목코드 중복 에러:', result);
|
||||
throw new DuplicateCodeError(
|
||||
result.message || '해당 품목코드가 이미 존재합니다.',
|
||||
result.duplicate_id,
|
||||
result.duplicate_code
|
||||
);
|
||||
}
|
||||
|
||||
const errorMessage = result.message || '품목 등록에 실패했습니다.';
|
||||
console.error('[CreateItemPage] 품목 등록 실패:', errorMessage);
|
||||
setSubmitError(errorMessage);
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
// 성공 시 DynamicItemForm 내부에서 /items로 리다이렉트 처리됨
|
||||
// console.log('[CreateItemPage] 품목 등록 성공:', result.data);
|
||||
|
||||
// 생성된 품목 ID를 포함한 데이터 반환 (파일 업로드용)
|
||||
return { id: result.data.id, ...result.data };
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<ItemForm mode="create" onSubmit={handleSubmit} />
|
||||
{submitError && (
|
||||
<div className="mb-4 p-4 bg-red-50 border border-red-200 rounded-lg text-red-900">
|
||||
⚠️ {submitError}
|
||||
</div>
|
||||
)}
|
||||
<DynamicItemForm
|
||||
mode="create"
|
||||
onSubmit={handleSubmit}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 품목 목록 페이지 (Client Component)
|
||||
* 품목 관리 페이지
|
||||
*
|
||||
* Next.js 15 App Router
|
||||
* 품목기준관리 API 연동
|
||||
* - 품목 목록: API에서 조회
|
||||
* - 테이블 컬럼: custom-tabs API에서 동적 구성
|
||||
*/
|
||||
|
||||
import ItemListClient from '@/components/items/ItemListClient';
|
||||
@@ -13,4 +13,12 @@ import ItemListClient from '@/components/items/ItemListClient';
|
||||
*/
|
||||
export default function ItemsPage() {
|
||||
return <ItemListClient />;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 메타데이터 설정
|
||||
*/
|
||||
export const metadata = {
|
||||
title: '품목 관리',
|
||||
description: '품목 목록 조회 및 관리',
|
||||
};
|
||||
@@ -1,10 +1,26 @@
|
||||
import { WorkOrderEdit } from '@/components/production/WorkOrders';
|
||||
'use client';
|
||||
|
||||
import { use, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export default async function WorkOrderEditPage({ params }: PageProps) {
|
||||
const { id } = await params;
|
||||
return <WorkOrderEdit orderId={id} />;
|
||||
}
|
||||
/**
|
||||
* V2 하위 호환성: /[id]/edit → /[id]?mode=edit 리다이렉트
|
||||
*/
|
||||
export default function WorkOrderEditPage({ params }: PageProps) {
|
||||
const { id } = use(params);
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
router.replace(`/production/work-orders/${id}?mode=edit`);
|
||||
}, [id, router]);
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-muted-foreground">리다이렉트 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,10 +3,12 @@
|
||||
/**
|
||||
* 작업지시 상세 페이지 (Client Component)
|
||||
* URL: /production/work-orders/[id]
|
||||
* V2 패턴: ?mode=edit로 수정 모드 전환
|
||||
*/
|
||||
|
||||
import { use } from 'react';
|
||||
import { WorkOrderDetail } from '@/components/production/WorkOrders';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { WorkOrderDetail, WorkOrderEdit } from '@/components/production/WorkOrders';
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{
|
||||
@@ -16,5 +18,13 @@ interface PageProps {
|
||||
|
||||
export default function WorkOrderDetailPage({ params }: PageProps) {
|
||||
const { id } = use(params);
|
||||
const searchParams = useSearchParams();
|
||||
const mode = searchParams.get('mode');
|
||||
const isEditMode = mode === 'edit';
|
||||
|
||||
if (isEditMode) {
|
||||
return <WorkOrderEdit orderId={id} />;
|
||||
}
|
||||
|
||||
return <WorkOrderDetail orderId={id} />;
|
||||
}
|
||||
@@ -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<ClientFormData | null>(
|
||||
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 <ContentLoadingSpinner text="거래처 정보를 불러오는 중..." />;
|
||||
}
|
||||
|
||||
if (!editingClient) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<ClientRegistration
|
||||
onBack={handleBack}
|
||||
onSave={handleSave}
|
||||
editingClient={editingClient}
|
||||
isLoading={hookLoading}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <ContentLoadingSpinner text="페이지 이동 중..." />;
|
||||
}
|
||||
|
||||
@@ -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<Client | null>(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 <ContentLoadingSpinner text="거래처 정보를 불러오는 중..." />;
|
||||
}
|
||||
|
||||
if (!client) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<ClientDetail
|
||||
client={client}
|
||||
onBack={handleBack}
|
||||
onEdit={handleEdit}
|
||||
onDelete={() => setShowDeleteDialog(true)}
|
||||
/>
|
||||
|
||||
{/* 삭제 확인 다이얼로그 */}
|
||||
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>거래처 삭제</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
'{client.name}' 거래처를 삭제하시겠습니까?
|
||||
<br />
|
||||
이 작업은 되돌릴 수 없습니다.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isDeleting}>취소</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleDelete}
|
||||
disabled={isDeleting}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
{isDeleting ? "삭제 중..." : "삭제"}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
return <ClientDetailClientV2 clientId={id} />;
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<ClientRegistration
|
||||
onBack={handleBack}
|
||||
onSave={handleSave}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <ClientDetailClientV2 />;
|
||||
}
|
||||
|
||||
@@ -1,559 +1,26 @@
|
||||
"use client";
|
||||
'use client';
|
||||
|
||||
import { use, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 수주 수정 페이지
|
||||
*
|
||||
* - 기본 정보 (읽기전용)
|
||||
* - 수주/배송 정보 (편집 가능)
|
||||
* - 비고 (편집 가능)
|
||||
* - 품목 내역 (생산 시작 후 수정 불가)
|
||||
* V2 하위 호환성: /[id]/edit → /[id]?mode=edit 리다이렉트
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useRouter, useParams } from "next/navigation";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { FileText, AlertTriangle } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { PageLayout } from "@/components/organisms/PageLayout";
|
||||
import { PageHeader } from "@/components/organisms/PageHeader";
|
||||
import { FormActions } from "@/components/organisms/FormActions";
|
||||
import { BadgeSm } from "@/components/atoms/BadgeSm";
|
||||
import { formatAmount } from "@/utils/formatAmount";
|
||||
import {
|
||||
OrderItem,
|
||||
getOrderById,
|
||||
updateOrder,
|
||||
type OrderStatus,
|
||||
} from "@/components/orders";
|
||||
|
||||
// 수정 폼 데이터
|
||||
interface EditFormData {
|
||||
// 읽기전용 정보
|
||||
lotNumber: string;
|
||||
quoteNumber: string;
|
||||
client: string;
|
||||
siteName: string;
|
||||
manager: string;
|
||||
contact: string;
|
||||
status: OrderStatus;
|
||||
|
||||
// 수정 가능 정보
|
||||
expectedShipDate: string;
|
||||
expectedShipDateUndecided: boolean;
|
||||
deliveryRequestDate: string;
|
||||
deliveryMethod: string;
|
||||
shippingCost: string;
|
||||
receiver: string;
|
||||
receiverContact: string;
|
||||
address: string;
|
||||
addressDetail: string;
|
||||
remarks: string;
|
||||
|
||||
// 품목 (수정 제한)
|
||||
items: OrderItem[];
|
||||
canEditItems: boolean;
|
||||
subtotal: number;
|
||||
discountRate: number;
|
||||
totalAmount: number;
|
||||
}
|
||||
|
||||
// 배송방식 옵션
|
||||
const DELIVERY_METHODS = [
|
||||
{ value: "direct", label: "직접배차" },
|
||||
{ value: "pickup", label: "상차" },
|
||||
{ value: "courier", label: "택배" },
|
||||
];
|
||||
|
||||
// 운임비용 옵션
|
||||
const SHIPPING_COSTS = [
|
||||
{ value: "free", label: "무료" },
|
||||
{ value: "prepaid", label: "선불" },
|
||||
{ value: "collect", label: "착불" },
|
||||
{ value: "negotiable", label: "협의" },
|
||||
];
|
||||
|
||||
|
||||
// 상태 뱃지 헬퍼
|
||||
function getOrderStatusBadge(status: OrderStatus) {
|
||||
const statusConfig: Record<OrderStatus, { label: string; className: string }> = {
|
||||
order_registered: { label: "수주등록", className: "bg-gray-100 text-gray-700 border-gray-200" },
|
||||
order_confirmed: { label: "수주확정", className: "bg-gray-100 text-gray-700 border-gray-200" },
|
||||
production_ordered: { label: "생산지시완료", className: "bg-blue-100 text-blue-700 border-blue-200" },
|
||||
in_production: { label: "생산중", className: "bg-green-100 text-green-700 border-green-200" },
|
||||
rework: { label: "재작업중", className: "bg-orange-100 text-orange-700 border-orange-200" },
|
||||
work_completed: { label: "작업완료", className: "bg-blue-600 text-white border-blue-600" },
|
||||
shipped: { label: "출하완료", className: "bg-gray-500 text-white border-gray-500" },
|
||||
cancelled: { label: "취소", className: "bg-red-100 text-red-700 border-red-200" },
|
||||
};
|
||||
const config = statusConfig[status];
|
||||
return (
|
||||
<BadgeSm className={config.className}>
|
||||
{config.label}
|
||||
</BadgeSm>
|
||||
);
|
||||
}
|
||||
|
||||
export default function OrderEditPage() {
|
||||
export default function OrderEditPage({ params }: PageProps) {
|
||||
const { id } = use(params);
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const orderId = params.id as string;
|
||||
|
||||
const [form, setForm] = useState<EditFormData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
// 데이터 로드 (API)
|
||||
useEffect(() => {
|
||||
async function loadOrder() {
|
||||
try {
|
||||
setLoading(true);
|
||||
const result = await getOrderById(orderId);
|
||||
if (result.success && result.data) {
|
||||
const order = result.data;
|
||||
// 상태에 따라 품목 수정 가능 여부 결정
|
||||
const canEditItems = !["in_production", "rework", "work_completed", "shipped"].includes(
|
||||
order.status
|
||||
);
|
||||
// Order 데이터를 EditFormData로 변환
|
||||
setForm({
|
||||
lotNumber: order.lotNumber,
|
||||
quoteNumber: order.quoteNumber || "",
|
||||
client: order.client,
|
||||
siteName: order.siteName,
|
||||
manager: order.manager || "",
|
||||
contact: order.contact || "",
|
||||
status: order.status,
|
||||
expectedShipDate: order.expectedShipDate || "",
|
||||
expectedShipDateUndecided: !order.expectedShipDate,
|
||||
deliveryRequestDate: order.deliveryRequestDate || "",
|
||||
deliveryMethod: order.deliveryMethod || "",
|
||||
shippingCost: order.shippingCost || "",
|
||||
receiver: order.receiver || "",
|
||||
receiverContact: order.receiverContact || "",
|
||||
address: order.address || "",
|
||||
addressDetail: order.addressDetail || "",
|
||||
remarks: order.remarks || "",
|
||||
items: order.items || [],
|
||||
canEditItems,
|
||||
subtotal: order.subtotal || order.amount,
|
||||
discountRate: order.discountRate || 0,
|
||||
totalAmount: order.amount,
|
||||
});
|
||||
} else {
|
||||
toast.error(result.error || "수주 정보를 불러오는데 실패했습니다.");
|
||||
router.push("/sales/order-management-sales");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error loading order:", error);
|
||||
toast.error("수주 정보를 불러오는 중 오류가 발생했습니다.");
|
||||
router.push("/sales/order-management-sales");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
loadOrder();
|
||||
}, [orderId, router]);
|
||||
|
||||
const handleCancel = () => {
|
||||
router.push(`/sales/order-management-sales/${orderId}`);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!form) return;
|
||||
|
||||
// 유효성 검사
|
||||
if (!form.deliveryRequestDate) {
|
||||
toast.error("납품요청일을 입력해주세요.");
|
||||
return;
|
||||
}
|
||||
if (!form.receiver.trim()) {
|
||||
toast.error("수신(반장/업체)을 입력해주세요.");
|
||||
return;
|
||||
}
|
||||
if (!form.receiverContact.trim()) {
|
||||
toast.error("수신처 연락처를 입력해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
try {
|
||||
// API 연동
|
||||
const result = await updateOrder(orderId, {
|
||||
clientId: undefined, // 기존 값 유지
|
||||
siteName: form.siteName,
|
||||
expectedShipDate: form.expectedShipDateUndecided ? undefined : form.expectedShipDate,
|
||||
deliveryRequestDate: form.deliveryRequestDate,
|
||||
deliveryMethod: form.deliveryMethod,
|
||||
shippingCost: form.shippingCost,
|
||||
receiver: form.receiver,
|
||||
receiverContact: form.receiverContact,
|
||||
address: form.address,
|
||||
addressDetail: form.addressDetail,
|
||||
remarks: form.remarks,
|
||||
items: form.items.map((item) => ({
|
||||
itemId: item.id ? parseInt(item.id, 10) : undefined,
|
||||
itemCode: item.itemCode,
|
||||
itemName: item.itemName,
|
||||
specification: item.spec,
|
||||
quantity: item.quantity,
|
||||
unit: item.unit,
|
||||
unitPrice: item.unitPrice,
|
||||
})),
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
toast.success("수주가 수정되었습니다.");
|
||||
router.push(`/sales/order-management-sales/${orderId}`);
|
||||
} else {
|
||||
toast.error(result.error || "수주 수정에 실패했습니다.");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error updating order:", error);
|
||||
toast.error("수주 수정 중 오류가 발생했습니다.");
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading || !form) {
|
||||
return (
|
||||
<PageLayout>
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
router.replace(`/sales/order-management-sales/${id}?mode=edit`);
|
||||
}, [id, router]);
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
{/* 헤더 */}
|
||||
<PageHeader
|
||||
title="수주 수정"
|
||||
icon={FileText}
|
||||
actions={
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="text-sm font-mono bg-gray-100 px-2 py-1 rounded">
|
||||
{form.lotNumber}
|
||||
</code>
|
||||
{getOrderStatusBadge(form.status)}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* 기본 정보 (읽기전용) */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
기본 정보
|
||||
<span className="text-sm font-normal text-muted-foreground">(읽기전용)</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-muted-foreground text-sm">로트번호</Label>
|
||||
<p className="font-medium">{form.lotNumber}</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-muted-foreground text-sm">견적번호</Label>
|
||||
<p className="font-medium">{form.quoteNumber}</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-muted-foreground text-sm">담당자</Label>
|
||||
<p className="font-medium">{form.manager}</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-muted-foreground text-sm">발주처</Label>
|
||||
<p className="font-medium">{form.client}</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-muted-foreground text-sm">현장명</Label>
|
||||
<p className="font-medium">{form.siteName}</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-muted-foreground text-sm">연락처</Label>
|
||||
<p className="font-medium">{form.contact}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 수주/배송 정보 (편집 가능) */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">수주/배송 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* 출고예정일 */}
|
||||
<div className="space-y-2">
|
||||
<Label>출고예정일</Label>
|
||||
<div className="flex items-center gap-4">
|
||||
<Input
|
||||
type="date"
|
||||
value={form.expectedShipDate}
|
||||
onChange={(e) =>
|
||||
setForm({ ...form, expectedShipDate: e.target.value })
|
||||
}
|
||||
disabled={form.expectedShipDateUndecided}
|
||||
className="flex-1"
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="expectedShipDateUndecided"
|
||||
checked={form.expectedShipDateUndecided}
|
||||
onCheckedChange={(checked) =>
|
||||
setForm({
|
||||
...form,
|
||||
expectedShipDateUndecided: checked as boolean,
|
||||
expectedShipDate: checked ? "" : form.expectedShipDate,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<Label
|
||||
htmlFor="expectedShipDateUndecided"
|
||||
className="text-sm font-normal"
|
||||
>
|
||||
미정
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 납품요청일 */}
|
||||
<div className="space-y-2">
|
||||
<Label>
|
||||
납품요청일 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
type="date"
|
||||
value={form.deliveryRequestDate}
|
||||
onChange={(e) =>
|
||||
setForm({ ...form, deliveryRequestDate: e.target.value })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 배송방식 */}
|
||||
<div className="space-y-2">
|
||||
<Label>배송방식</Label>
|
||||
<Select
|
||||
value={form.deliveryMethod}
|
||||
onValueChange={(value) =>
|
||||
setForm({ ...form, deliveryMethod: value })
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="배송방식 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{DELIVERY_METHODS.map((method) => (
|
||||
<SelectItem key={method.value} value={method.value}>
|
||||
{method.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 운임비용 */}
|
||||
<div className="space-y-2">
|
||||
<Label>운임비용</Label>
|
||||
<Select
|
||||
value={form.shippingCost}
|
||||
onValueChange={(value) =>
|
||||
setForm({ ...form, shippingCost: value })
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="운임비용 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SHIPPING_COSTS.map((cost) => (
|
||||
<SelectItem key={cost.value} value={cost.value}>
|
||||
{cost.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 수신(반장/업체) */}
|
||||
<div className="space-y-2">
|
||||
<Label>
|
||||
수신(반장/업체) <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
value={form.receiver}
|
||||
onChange={(e) =>
|
||||
setForm({ ...form, receiver: e.target.value })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 수신처 연락처 */}
|
||||
<div className="space-y-2">
|
||||
<Label>
|
||||
수신처 연락처 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
value={form.receiverContact}
|
||||
onChange={(e) =>
|
||||
setForm({ ...form, receiverContact: e.target.value })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 수신처 주소 */}
|
||||
<div className="space-y-2 md:col-span-2">
|
||||
<Label>수신처 주소</Label>
|
||||
<Input
|
||||
value={form.address}
|
||||
onChange={(e) =>
|
||||
setForm({ ...form, address: e.target.value })
|
||||
}
|
||||
placeholder="주소"
|
||||
className="mb-2"
|
||||
/>
|
||||
<Input
|
||||
value={form.addressDetail}
|
||||
onChange={(e) =>
|
||||
setForm({ ...form, addressDetail: e.target.value })
|
||||
}
|
||||
placeholder="상세주소"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 비고 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">비고</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Textarea
|
||||
value={form.remarks}
|
||||
onChange={(e) => setForm({ ...form, remarks: e.target.value })}
|
||||
placeholder="특이사항을 입력하세요"
|
||||
rows={4}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 품목 내역 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
품목 내역
|
||||
{!form.canEditItems && (
|
||||
<span className="flex items-center gap-1 text-sm font-normal text-orange-600">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
생산 시작 후 수정 불가
|
||||
</span>
|
||||
)}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[60px] text-center">No</TableHead>
|
||||
<TableHead>품목코드</TableHead>
|
||||
<TableHead>품명</TableHead>
|
||||
<TableHead>종</TableHead>
|
||||
<TableHead>부호</TableHead>
|
||||
<TableHead>규격(mm)</TableHead>
|
||||
<TableHead className="text-center">수량</TableHead>
|
||||
<TableHead className="text-center">단위</TableHead>
|
||||
<TableHead className="text-right">단가</TableHead>
|
||||
<TableHead className="text-right">금액</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{form.items.map((item, index) => (
|
||||
<TableRow key={item.id}>
|
||||
<TableCell className="text-center">{index + 1}</TableCell>
|
||||
<TableCell>
|
||||
<code className="text-xs bg-gray-100 px-1.5 py-0.5 rounded">
|
||||
{item.itemCode}
|
||||
</code>
|
||||
</TableCell>
|
||||
<TableCell>{item.itemName}</TableCell>
|
||||
<TableCell>{item.type || "-"}</TableCell>
|
||||
<TableCell>{item.symbol || "-"}</TableCell>
|
||||
<TableCell>{item.spec}</TableCell>
|
||||
<TableCell className="text-center">{item.quantity}</TableCell>
|
||||
<TableCell className="text-center">{item.unit}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{formatAmount(item.unitPrice)}원
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-medium">
|
||||
{formatAmount(item.amount)}원
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* 합계 */}
|
||||
<div className="flex flex-col items-end gap-2 pt-4 mt-4 border-t">
|
||||
<div className="flex items-center gap-4 text-sm">
|
||||
<span className="text-muted-foreground">소계:</span>
|
||||
<span className="w-32 text-right">
|
||||
{formatAmount(form.subtotal)}원
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-sm">
|
||||
<span className="text-muted-foreground">할인율:</span>
|
||||
<span className="w-32 text-right">{form.discountRate}%</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-lg font-semibold">
|
||||
<span>총금액:</span>
|
||||
<span className="w-32 text-right text-green-600">
|
||||
{formatAmount(form.totalAmount)}원
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 하단 버튼 */}
|
||||
<div className="sticky bottom-0 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 border-t pt-4 pb-4 -mx-3 md:-mx-6 px-3 md:px-6 mt-6">
|
||||
<FormActions
|
||||
onSave={handleSave}
|
||||
onCancel={handleCancel}
|
||||
saveLabel="저장"
|
||||
cancelLabel="취소"
|
||||
saveLoading={isSaving}
|
||||
saveDisabled={isSaving}
|
||||
/>
|
||||
</div>
|
||||
</PageLayout>
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-muted-foreground">리다이렉트 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,662 +1,26 @@
|
||||
"use client";
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 수주 상세 페이지
|
||||
*
|
||||
* - 문서 모달: 계약서, 거래명세서, 발주서
|
||||
* - 기본 정보, 수주/배송 정보, 비고
|
||||
* - 제품 내역 테이블
|
||||
* - 상태별 버튼 차이
|
||||
* 수주 상세/수정 페이지 (Client Component)
|
||||
* V2 패턴: ?mode=edit로 수정 모드 전환
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useRouter, useParams } from "next/navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import {
|
||||
FileText,
|
||||
ArrowLeft,
|
||||
Edit,
|
||||
Factory,
|
||||
XCircle,
|
||||
FileSpreadsheet,
|
||||
FileCheck,
|
||||
ClipboardList,
|
||||
Eye,
|
||||
CheckCircle2,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { PageLayout } from "@/components/organisms/PageLayout";
|
||||
import { PageHeader } from "@/components/organisms/PageHeader";
|
||||
import { BadgeSm } from "@/components/atoms/BadgeSm";
|
||||
import { formatAmount } from "@/utils/formatAmount";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
OrderDocumentModal,
|
||||
type OrderDocumentType,
|
||||
getOrderById,
|
||||
updateOrderStatus,
|
||||
type Order,
|
||||
type OrderStatus,
|
||||
} from "@/components/orders";
|
||||
import { use } from 'react';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { OrderSalesDetailView, OrderSalesDetailEdit } from '@/components/orders';
|
||||
|
||||
|
||||
// 상태 뱃지 헬퍼
|
||||
function getOrderStatusBadge(status: OrderStatus) {
|
||||
const statusConfig: Record<OrderStatus, { label: string; className: string }> = {
|
||||
order_registered: { label: "수주등록", className: "bg-gray-100 text-gray-700 border-gray-200" },
|
||||
order_confirmed: { label: "수주확정", className: "bg-gray-100 text-gray-700 border-gray-200" },
|
||||
production_ordered: { label: "생산지시완료", className: "bg-blue-100 text-blue-700 border-blue-200" },
|
||||
in_production: { label: "생산중", className: "bg-green-100 text-green-700 border-green-200" },
|
||||
rework: { label: "재작업중", className: "bg-orange-100 text-orange-700 border-orange-200" },
|
||||
work_completed: { label: "작업완료", className: "bg-blue-600 text-white border-blue-600" },
|
||||
shipped: { label: "출하완료", className: "bg-gray-500 text-white border-gray-500" },
|
||||
cancelled: { label: "취소", className: "bg-red-100 text-red-700 border-red-200" },
|
||||
};
|
||||
const config = statusConfig[status];
|
||||
return (
|
||||
<BadgeSm className={config.className}>
|
||||
{config.label}
|
||||
</BadgeSm>
|
||||
);
|
||||
interface PageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
// 정보 표시 컴포넌트
|
||||
function InfoItem({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">{label}</p>
|
||||
<p className="font-medium">{value || "-"}</p>
|
||||
</div>
|
||||
);
|
||||
export default function OrderDetailPage({ params }: PageProps) {
|
||||
const { id } = use(params);
|
||||
const searchParams = useSearchParams();
|
||||
const mode = searchParams.get('mode') === 'edit' ? 'edit' : 'view';
|
||||
|
||||
if (mode === 'edit') {
|
||||
return <OrderSalesDetailEdit orderId={id} />;
|
||||
}
|
||||
|
||||
return <OrderSalesDetailView orderId={id} />;
|
||||
}
|
||||
|
||||
export default function OrderDetailPage() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const orderId = params.id as string;
|
||||
|
||||
const [order, setOrder] = useState<Order | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [isCancelDialogOpen, setIsCancelDialogOpen] = useState(false);
|
||||
const [isCancelling, setIsCancelling] = useState(false);
|
||||
const [isConfirmDialogOpen, setIsConfirmDialogOpen] = useState(false);
|
||||
const [isConfirming, setIsConfirming] = useState(false);
|
||||
|
||||
// 취소 폼 상태
|
||||
const [cancelReason, setCancelReason] = useState("");
|
||||
const [cancelDetail, setCancelDetail] = useState("");
|
||||
|
||||
// 문서 모달 상태
|
||||
const [documentModalOpen, setDocumentModalOpen] = useState(false);
|
||||
const [documentType, setDocumentType] = useState<OrderDocumentType>("contract");
|
||||
|
||||
// 데이터 로드 (API 호출)
|
||||
useEffect(() => {
|
||||
async function loadOrder() {
|
||||
try {
|
||||
setLoading(true);
|
||||
const result = await getOrderById(orderId);
|
||||
if (result.success && result.data) {
|
||||
setOrder(result.data);
|
||||
} else {
|
||||
toast.error(result.error || "수주 정보를 불러오는데 실패했습니다.");
|
||||
setOrder(null);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error loading order:", error);
|
||||
toast.error("수주 정보를 불러오는 중 오류가 발생했습니다.");
|
||||
setOrder(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
loadOrder();
|
||||
}, [orderId]);
|
||||
|
||||
const handleBack = () => {
|
||||
router.push("/sales/order-management-sales");
|
||||
};
|
||||
|
||||
const handleEdit = () => {
|
||||
router.push(`/sales/order-management-sales/${orderId}/edit`);
|
||||
};
|
||||
|
||||
const handleProductionOrder = () => {
|
||||
// 생산지시 생성 페이지로 이동
|
||||
router.push(`/sales/order-management-sales/${orderId}/production-order`);
|
||||
};
|
||||
|
||||
const handleViewProductionOrder = () => {
|
||||
// 생산지시 목록 페이지로 이동 (수주관리 내부)
|
||||
router.push(`/sales/order-management-sales/production-orders`);
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setCancelReason("");
|
||||
setCancelDetail("");
|
||||
setIsCancelDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleConfirmCancel = async () => {
|
||||
if (!cancelReason) {
|
||||
toast.error("취소 사유를 선택해주세요.");
|
||||
return;
|
||||
}
|
||||
if (order) {
|
||||
setIsCancelling(true);
|
||||
try {
|
||||
const result = await updateOrderStatus(order.id, "cancelled");
|
||||
if (result.success) {
|
||||
setOrder({ ...order, status: "cancelled" });
|
||||
toast.success("수주가 취소되었습니다.");
|
||||
setIsCancelDialogOpen(false);
|
||||
setCancelReason("");
|
||||
setCancelDetail("");
|
||||
} else {
|
||||
toast.error(result.error || "수주 취소에 실패했습니다.");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error cancelling order:", error);
|
||||
toast.error("수주 취소 중 오류가 발생했습니다.");
|
||||
} finally {
|
||||
setIsCancelling(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 수주 확정 처리
|
||||
const handleConfirmOrder = () => {
|
||||
setIsConfirmDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleConfirmOrderSubmit = async () => {
|
||||
if (order) {
|
||||
setIsConfirming(true);
|
||||
try {
|
||||
const result = await updateOrderStatus(order.id, "order_confirmed");
|
||||
if (result.success && result.data) {
|
||||
setOrder(result.data);
|
||||
toast.success("수주가 확정되었습니다.");
|
||||
setIsConfirmDialogOpen(false);
|
||||
} else {
|
||||
toast.error(result.error || "수주 확정에 실패했습니다.");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error confirming order:", error);
|
||||
toast.error("수주 확정 중 오류가 발생했습니다.");
|
||||
} finally {
|
||||
setIsConfirming(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 문서 모달 열기
|
||||
const openDocumentModal = (type: OrderDocumentType) => {
|
||||
setDocumentType(type);
|
||||
setDocumentModalOpen(true);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<PageLayout>
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
if (!order) {
|
||||
return (
|
||||
<PageLayout>
|
||||
<div className="text-center py-12">
|
||||
<p className="text-muted-foreground">수주 정보를 찾을 수 없습니다.</p>
|
||||
<Button variant="outline" onClick={handleBack} className="mt-4">
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
목록으로
|
||||
</Button>
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
// 상태별 버튼 표시 여부
|
||||
const showEditButton = order.status !== "shipped" && order.status !== "cancelled";
|
||||
// 수주 확정 버튼: 수주등록 상태에서만 표시
|
||||
const showConfirmButton = order.status === "order_registered";
|
||||
// 생산지시 생성 버튼: 출하완료, 취소, 생산지시완료 제외하고 표시
|
||||
// (수주등록, 수주확정, 생산중, 재작업중, 작업완료에서 표시)
|
||||
const showProductionCreateButton =
|
||||
order.status !== "shipped" &&
|
||||
order.status !== "cancelled" &&
|
||||
order.status !== "production_ordered";
|
||||
// 생산지시 보기 버튼: 생산지시완료 상태에서 숨김 (기획서 오류로 제거)
|
||||
const showProductionViewButton = false;
|
||||
const showCancelButton =
|
||||
order.status !== "shipped" &&
|
||||
order.status !== "cancelled" &&
|
||||
order.status !== "production_ordered";
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
{/* 헤더 */}
|
||||
<PageHeader
|
||||
title="수주 상세"
|
||||
icon={FileText}
|
||||
actions={
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Button variant="outline" onClick={handleBack}>
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
목록
|
||||
</Button>
|
||||
{showEditButton && (
|
||||
<Button variant="outline" onClick={handleEdit}>
|
||||
<Edit className="h-4 w-4 mr-2" />
|
||||
수정
|
||||
</Button>
|
||||
)}
|
||||
{showConfirmButton && (
|
||||
<Button onClick={handleConfirmOrder} className="bg-green-600 hover:bg-green-700">
|
||||
<CheckCircle2 className="h-4 w-4 mr-2" />
|
||||
수주 확정
|
||||
</Button>
|
||||
)}
|
||||
{showProductionCreateButton && (
|
||||
<Button onClick={handleProductionOrder}>
|
||||
<Factory className="h-4 w-4 mr-2" />
|
||||
생산지시 생성
|
||||
</Button>
|
||||
)}
|
||||
{showProductionViewButton && (
|
||||
<Button onClick={handleViewProductionOrder}>
|
||||
<Eye className="h-4 w-4 mr-2" />
|
||||
생산지시 보기
|
||||
</Button>
|
||||
)}
|
||||
{showCancelButton && (
|
||||
<Button variant="outline" onClick={handleCancel} className="border-orange-200 text-orange-600 hover:border-orange-300">
|
||||
<XCircle className="h-4 w-4 mr-2" />
|
||||
취소
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* 수주 정보 헤더 */}
|
||||
<Card>
|
||||
<CardHeader className="pb-4">
|
||||
<div className="flex items-center justify-between flex-wrap gap-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<code className="text-sm font-mono bg-gray-100 px-2 py-1 rounded">
|
||||
{order.lotNumber}
|
||||
</code>
|
||||
{getOrderStatusBadge(order.status)}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
수주일: {order.orderDate}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
{/* 문서 버튼들 */}
|
||||
<div className="flex items-center gap-2 pt-4 border-t">
|
||||
<span className="text-sm text-muted-foreground mr-2">문서:</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => openDocumentModal("contract")}
|
||||
>
|
||||
<FileCheck className="h-4 w-4 mr-1" />
|
||||
계약서
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => openDocumentModal("transaction")}
|
||||
>
|
||||
<FileSpreadsheet className="h-4 w-4 mr-1" />
|
||||
거래명세서
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => openDocumentModal("purchaseOrder")}
|
||||
>
|
||||
<ClipboardList className="h-4 w-4 mr-1" />
|
||||
발주서
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 기본 정보 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">기본 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-6">
|
||||
<InfoItem label="발주처" value={order.client} />
|
||||
<InfoItem label="현장명" value={order.siteName} />
|
||||
<InfoItem label="담당자" value={order.manager} />
|
||||
<InfoItem label="연락처" value={order.contact} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 수주/배송 정보 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">수주/배송 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-6">
|
||||
<InfoItem label="수주일자" value={order.orderDate} />
|
||||
<InfoItem label="출고예정일" value={order.expectedShipDate || "미정"} />
|
||||
<InfoItem label="납품요청일" value={order.deliveryRequestDate} />
|
||||
<InfoItem label="배송방식" value={order.deliveryMethod} />
|
||||
<InfoItem label="운임비용" value={order.shippingCost} />
|
||||
<InfoItem label="수신(반장/업체)" value={order.receiver} />
|
||||
<InfoItem label="수신처 연락처" value={order.receiverContact} />
|
||||
<InfoItem label="수신처 주소" value={order.address} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 비고 */}
|
||||
{order.remarks && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">비고</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="whitespace-pre-wrap">{order.remarks}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 제품 내역 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">제품 내역</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[60px] text-center">순번</TableHead>
|
||||
<TableHead>품목코드</TableHead>
|
||||
<TableHead>품명</TableHead>
|
||||
<TableHead>층</TableHead>
|
||||
<TableHead>부호</TableHead>
|
||||
<TableHead>규격</TableHead>
|
||||
<TableHead className="text-center">수량</TableHead>
|
||||
<TableHead className="text-center">단위</TableHead>
|
||||
<TableHead className="text-right">단가</TableHead>
|
||||
<TableHead className="text-right">금액</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{order.items.map((item, index) => (
|
||||
<TableRow key={item.id}>
|
||||
<TableCell className="text-center">{index + 1}</TableCell>
|
||||
<TableCell>
|
||||
<code className="text-xs bg-gray-100 px-1.5 py-0.5 rounded">
|
||||
{item.itemCode}
|
||||
</code>
|
||||
</TableCell>
|
||||
<TableCell>{item.itemName}</TableCell>
|
||||
<TableCell>{item.type || "-"}</TableCell>
|
||||
<TableCell>{item.symbol || "-"}</TableCell>
|
||||
<TableCell>{item.spec}</TableCell>
|
||||
<TableCell className="text-center">{item.quantity}</TableCell>
|
||||
<TableCell className="text-center">{item.unit}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{formatAmount(item.unitPrice)}원
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-medium">
|
||||
{formatAmount(item.amount)}원
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* 합계 */}
|
||||
<div className="flex flex-col items-end gap-2 pt-4 mt-4 border-t">
|
||||
<div className="flex items-center gap-4 text-sm">
|
||||
<span className="text-muted-foreground">소계:</span>
|
||||
<span className="w-32 text-right">
|
||||
{formatAmount(order.subtotal)}원
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-sm">
|
||||
<span className="text-muted-foreground">할인율:</span>
|
||||
<span className="w-32 text-right">{order.discountRate}%</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-lg font-semibold">
|
||||
<span>총금액:</span>
|
||||
<span className="w-32 text-right text-green-600">
|
||||
{formatAmount(order.totalAmount)}원
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 문서 모달 */}
|
||||
<OrderDocumentModal
|
||||
open={documentModalOpen}
|
||||
onOpenChange={setDocumentModalOpen}
|
||||
documentType={documentType}
|
||||
data={{
|
||||
lotNumber: order.lotNumber,
|
||||
orderDate: order.orderDate,
|
||||
client: order.client,
|
||||
siteName: order.siteName,
|
||||
manager: order.manager,
|
||||
managerContact: order.contact,
|
||||
deliveryRequestDate: order.deliveryRequestDate,
|
||||
expectedShipDate: order.expectedShipDate,
|
||||
deliveryMethod: order.deliveryMethod,
|
||||
address: order.address,
|
||||
items: order.items,
|
||||
subtotal: order.subtotal,
|
||||
discountRate: order.discountRate,
|
||||
totalAmount: order.totalAmount,
|
||||
remarks: order.remarks,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 취소 확인 다이얼로그 */}
|
||||
<Dialog open={isCancelDialogOpen} onOpenChange={setIsCancelDialogOpen}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<XCircle className="h-5 w-5" />
|
||||
수주 취소
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* 수주 정보 박스 */}
|
||||
<div className="border rounded-lg p-4 space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">수주번호</span>
|
||||
<span className="font-medium">{order.lotNumber}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">발주처</span>
|
||||
<span>{order.client}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">현장명</span>
|
||||
<span>{order.siteName}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-muted-foreground">현재 상태</span>
|
||||
{getOrderStatusBadge(order.status)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 취소 사유 선택 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="cancelReason">
|
||||
취소 사유 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Select value={cancelReason} onValueChange={setCancelReason}>
|
||||
<SelectTrigger id="cancelReason">
|
||||
<SelectValue placeholder="취소 사유를 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="customer_request">고객 요청</SelectItem>
|
||||
<SelectItem value="spec_change">사양 변경</SelectItem>
|
||||
<SelectItem value="price_issue">가격 문제</SelectItem>
|
||||
<SelectItem value="delivery_issue">납기 문제</SelectItem>
|
||||
<SelectItem value="duplicate_order">중복 수주</SelectItem>
|
||||
<SelectItem value="other">기타</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 상세 사유 입력 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="cancelDetail">상세 사유</Label>
|
||||
<Textarea
|
||||
id="cancelDetail"
|
||||
placeholder="취소 사유에 대한 상세 내용을 입력하세요"
|
||||
value={cancelDetail}
|
||||
onChange={(e) => setCancelDetail(e.target.value)}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 취소 시 유의사항 */}
|
||||
<div className="bg-gray-50 border rounded-lg p-4 text-sm space-y-1">
|
||||
<p className="font-medium mb-2">취소 시 유의사항</p>
|
||||
<ul className="space-y-1 text-muted-foreground">
|
||||
<li>• 취소된 수주는 목록에서 '취소' 상태로 표시됩니다</li>
|
||||
<li>• 취소 후에는 수정이 불가능합니다</li>
|
||||
<li>• 관련된 생산지시가 있는 경우 먼저 생산지시를 취소해야 합니다</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setIsCancelDialogOpen(false)}
|
||||
>
|
||||
닫기
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleConfirmCancel}
|
||||
className="border-gray-300"
|
||||
disabled={isCancelling}
|
||||
>
|
||||
<XCircle className="h-4 w-4 mr-1" />
|
||||
{isCancelling ? "취소 중..." : "취소 확정"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 수주 확정 다이얼로그 */}
|
||||
<Dialog open={isConfirmDialogOpen} onOpenChange={setIsConfirmDialogOpen}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<CheckCircle2 className="h-5 w-5 text-green-600" />
|
||||
수주 확정
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* 수주 정보 박스 */}
|
||||
<div className="border rounded-lg p-4 space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">수주번호</span>
|
||||
<span className="font-medium">{order.lotNumber}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">발주처</span>
|
||||
<span>{order.client}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">현장명</span>
|
||||
<span>{order.siteName}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">총금액</span>
|
||||
<span className="font-medium text-green-600">
|
||||
{formatAmount(order.totalAmount)}원
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-muted-foreground">현재 상태</span>
|
||||
{getOrderStatusBadge(order.status)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 확정 안내 */}
|
||||
<div className="bg-green-50 border border-green-200 rounded-lg p-4 text-sm space-y-1">
|
||||
<p className="font-medium mb-2 text-green-700">확정 후 변경사항</p>
|
||||
<ul className="space-y-1 text-green-600">
|
||||
<li>• 수주 상태가 '수주확정'으로 변경됩니다</li>
|
||||
<li>• 생산지시를 생성할 수 있습니다</li>
|
||||
<li>• 확정 후에도 수정이 가능합니다</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setIsConfirmDialogOpen(false)}
|
||||
>
|
||||
닫기
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleConfirmOrderSubmit}
|
||||
className="bg-green-600 hover:bg-green-700"
|
||||
disabled={isConfirming}
|
||||
>
|
||||
<CheckCircle2 className="h-4 w-4 mr-1" />
|
||||
{isConfirming ? "확정 중..." : "확정"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
@@ -45,6 +45,7 @@ import {
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { toast } from "sonner";
|
||||
import { ServerErrorPage } from "@/components/common/ServerErrorPage";
|
||||
|
||||
// 생산지시 상태 타입
|
||||
type ProductionOrderStatus = "waiting" | "in_progress" | "completed";
|
||||
@@ -457,15 +458,12 @@ export default function ProductionOrderDetailPage() {
|
||||
|
||||
if (!productionOrder) {
|
||||
return (
|
||||
<PageLayout>
|
||||
<div className="text-center py-12">
|
||||
<p className="text-muted-foreground">생산지시 정보를 찾을 수 없습니다.</p>
|
||||
<Button variant="outline" onClick={handleBack} className="mt-4">
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
목록으로
|
||||
</Button>
|
||||
</div>
|
||||
</PageLayout>
|
||||
<ServerErrorPage
|
||||
title="생산지시 정보를 불러올 수 없습니다"
|
||||
message="생산지시 정보를 찾을 수 없습니다."
|
||||
showBackButton={true}
|
||||
showHomeButton={true}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,17 +1,7 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 단가 수정 페이지 (Client Component)
|
||||
*
|
||||
* 경로: /sales/pricing-management/[id]/edit
|
||||
* API: GET /api/v1/pricing/{id}, PUT /api/v1/pricing/{id}
|
||||
*/
|
||||
|
||||
import { use, useEffect, useState } from 'react';
|
||||
import { use, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { PricingFormClient } from '@/components/pricing';
|
||||
import { getPricingById, updatePricing, finalizePricing } from '@/components/pricing/actions';
|
||||
import type { PricingData } from '@/components/pricing';
|
||||
|
||||
interface EditPricingPageProps {
|
||||
params: Promise<{
|
||||
@@ -19,79 +9,20 @@ interface EditPricingPageProps {
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* V2 하위 호환성: /[id]/edit → /[id]?mode=edit 리다이렉트
|
||||
*/
|
||||
export default function EditPricingPage({ params }: EditPricingPageProps) {
|
||||
const { id } = use(params);
|
||||
const router = useRouter();
|
||||
const [data, setData] = useState<PricingData | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
getPricingById(id)
|
||||
.then(result => {
|
||||
if (result) {
|
||||
setData(result);
|
||||
} else {
|
||||
setError('단가 정보를 찾을 수 없습니다.');
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
setError('단가 정보를 불러오는 중 오류가 발생했습니다.');
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
}, [id]);
|
||||
|
||||
// 단가 수정 핸들러
|
||||
const handleSave = async (formData: PricingData, isRevision?: boolean, revisionReason?: string) => {
|
||||
const result = await updatePricing(id, formData, revisionReason);
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || '단가 수정에 실패했습니다.');
|
||||
}
|
||||
console.log('[EditPricingPage] 단가 수정 성공:', result.data, { isRevision, revisionReason });
|
||||
};
|
||||
|
||||
// 단가 확정 핸들러
|
||||
const handleFinalize = async (priceId: string) => {
|
||||
const result = await finalizePricing(priceId);
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || '단가 확정에 실패했습니다.');
|
||||
}
|
||||
console.log('[EditPricingPage] 단가 확정 성공:', result.data);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-muted-foreground">로딩 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !data) {
|
||||
return (
|
||||
<div className="container mx-auto py-6 px-4">
|
||||
<div className="text-center py-12">
|
||||
<h2 className="text-xl font-semibold mb-2">{error || '단가 정보를 찾을 수 없습니다'}</h2>
|
||||
<p className="text-muted-foreground mb-4">
|
||||
올바른 단가 정보로 다시 시도해주세요.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => router.back()}
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
뒤로 가기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
router.replace(`/sales/pricing-management/${id}?mode=edit`);
|
||||
}, [id, router]);
|
||||
|
||||
return (
|
||||
<PricingFormClient
|
||||
mode="edit"
|
||||
initialData={data}
|
||||
onSave={handleSave}
|
||||
onFinalize={handleFinalize}
|
||||
/>
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-muted-foreground">리다이렉트 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 단가 상세/수정 페이지 (Client Component)
|
||||
* V2 패턴: ?mode=edit로 수정 모드 전환
|
||||
*
|
||||
* 경로: /sales/pricing-management/[id]
|
||||
* 수정 모드: /sales/pricing-management/[id]?mode=edit
|
||||
*/
|
||||
|
||||
import { use, useEffect, useState } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { PricingFormClient } from '@/components/pricing';
|
||||
import { getPricingById, updatePricing, finalizePricing } from '@/components/pricing/actions';
|
||||
import type { PricingData } from '@/components/pricing';
|
||||
import { ServerErrorPage } from '@/components/common/ServerErrorPage';
|
||||
|
||||
interface PricingDetailPageProps {
|
||||
params: Promise<{
|
||||
id: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export default function PricingDetailPage({ params }: PricingDetailPageProps) {
|
||||
const { id } = use(params);
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const mode = searchParams.get('mode') === 'edit' ? 'edit' : 'view';
|
||||
const [data, setData] = useState<PricingData | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
getPricingById(id)
|
||||
.then(result => {
|
||||
if (result) {
|
||||
setData(result);
|
||||
} else {
|
||||
setError('단가 정보를 찾을 수 없습니다.');
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
setError('단가 정보를 불러오는 중 오류가 발생했습니다.');
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
}, [id]);
|
||||
|
||||
// 단가 수정 핸들러
|
||||
const handleSave = async (formData: PricingData, isRevision?: boolean, revisionReason?: string) => {
|
||||
const result = await updatePricing(id, formData, revisionReason);
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || '단가 수정에 실패했습니다.');
|
||||
}
|
||||
console.log('[PricingDetailPage] 단가 수정 성공:', result.data, { isRevision, revisionReason });
|
||||
};
|
||||
|
||||
// 단가 확정 핸들러
|
||||
const handleFinalize = async (priceId: string) => {
|
||||
const result = await finalizePricing(priceId);
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || '단가 확정에 실패했습니다.');
|
||||
}
|
||||
console.log('[PricingDetailPage] 단가 확정 성공:', result.data);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-muted-foreground">로딩 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !data) {
|
||||
return (
|
||||
<ServerErrorPage
|
||||
title="단가 정보를 불러올 수 없습니다"
|
||||
message={error || '단가 정보를 찾을 수 없습니다.'}
|
||||
showBackButton={true}
|
||||
showHomeButton={true}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PricingFormClient
|
||||
mode={mode}
|
||||
initialData={data}
|
||||
onSave={handleSave}
|
||||
onFinalize={handleFinalize}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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<QuoteFormData | null>(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 <ContentLoadingSpinner text="견적 정보를 불러오는 중..." />;
|
||||
}
|
||||
router.replace(`/ko/sales/quote-management/${id}?mode=edit`);
|
||||
}, [id, router]);
|
||||
|
||||
return (
|
||||
<QuoteRegistration
|
||||
onBack={handleBack}
|
||||
onSave={handleSave}
|
||||
editingQuote={quote}
|
||||
/>
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-muted-foreground">리다이렉트 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<QuoteFormData | null>(null);
|
||||
const [companyInfo, setCompanyInfo] = useState<CompanyFormData | null>(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 (
|
||||
<QuoteRegistration
|
||||
onBack={handleBack}
|
||||
onSave={handleSave}
|
||||
editingQuote={quote}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// View 모드: 상세 보기
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
{/* 헤더 */}
|
||||
|
||||
@@ -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<QuoteFormDataV2 | null>(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 <ContentLoadingSpinner text="견적 정보를 불러오는 중..." />;
|
||||
}
|
||||
router.replace(`/ko/sales/quote-management/test/${id}?mode=edit`);
|
||||
}, [id, router]);
|
||||
|
||||
return (
|
||||
<QuoteRegistrationV2
|
||||
mode="edit"
|
||||
onBack={handleBack}
|
||||
onSave={handleSave}
|
||||
initialData={quote}
|
||||
isLoading={isSaving}
|
||||
/>
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-muted-foreground">리다이렉트 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,16 +1,21 @@
|
||||
/**
|
||||
* 견적 상세 테스트 페이지 (V2 UI)
|
||||
* 견적 상세/수정 테스트 페이지 (V2 UI 통합)
|
||||
*
|
||||
* IntegratedDetailTemplate 마이그레이션 완료 (2026-01-20)
|
||||
* 새로운 자동 견적 산출 UI 테스트용
|
||||
* 기존 견적 상세 페이지는 수정하지 않음
|
||||
* URL 패턴:
|
||||
* - /quote-management/test/[id] → 상세 보기 (view)
|
||||
* - /quote-management/test/[id]?mode=edit → 수정 모드 (edit)
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import { useRouter, useParams } from "next/navigation";
|
||||
import { useState, useEffect } from "react";
|
||||
import { useRouter, useParams, useSearchParams } from "next/navigation";
|
||||
import { useState, useEffect, useMemo, useCallback } from "react";
|
||||
import { QuoteRegistrationV2, QuoteFormDataV2, LocationItem } from "@/components/quotes/QuoteRegistrationV2";
|
||||
import { ContentLoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import { IntegratedDetailTemplate } from "@/components/templates/IntegratedDetailTemplate";
|
||||
import { quoteConfig } from "@/components/quotes/quoteConfig";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { toast } from "sonner";
|
||||
|
||||
// 테스트용 목업 데이터
|
||||
@@ -84,10 +89,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<QuoteFormDataV2 | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// 테스트용 데이터 로드 시뮬레이션
|
||||
@@ -108,19 +119,78 @@ export default function QuoteTestDetailPage() {
|
||||
loadQuote();
|
||||
}, [quoteId, router]);
|
||||
|
||||
const handleBack = () => {
|
||||
const handleBack = useCallback(() => {
|
||||
router.push("/sales/quote-management");
|
||||
};
|
||||
}, [router]);
|
||||
|
||||
if (isLoading) {
|
||||
return <ContentLoadingSpinner text="견적 정보를 불러오는 중..." />;
|
||||
}
|
||||
// V2 패턴: 수정 저장 핸들러
|
||||
const handleSave = useCallback(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);
|
||||
}
|
||||
}, [router, quoteId]);
|
||||
|
||||
// 동적 config (모드별 타이틀)
|
||||
const dynamicConfig = useMemo(() => {
|
||||
const title = isEditMode ? '견적 수정 (V2 테스트)' : '견적 상세 (V2 테스트)';
|
||||
return {
|
||||
...quoteConfig,
|
||||
title,
|
||||
};
|
||||
}, [isEditMode]);
|
||||
|
||||
// 커스텀 헤더 액션 (상태 뱃지)
|
||||
const customHeaderActions = useMemo(() => {
|
||||
if (!quote) return null;
|
||||
return (
|
||||
<Badge variant={quote.status === "final" ? "default" : quote.status === "temporary" ? "secondary" : "outline"}>
|
||||
{quote.status === "final" ? "최종저장" : quote.status === "temporary" ? "임시저장" : "작성중"}
|
||||
</Badge>
|
||||
);
|
||||
}, [quote]);
|
||||
|
||||
// 폼 콘텐츠 렌더링
|
||||
const renderFormContent = useCallback(() => {
|
||||
return (
|
||||
<QuoteRegistrationV2
|
||||
mode={isEditMode ? "edit" : "view"}
|
||||
onBack={handleBack}
|
||||
onSave={isEditMode ? handleSave : undefined}
|
||||
initialData={quote}
|
||||
isLoading={isSaving}
|
||||
hideHeader={true}
|
||||
/>
|
||||
);
|
||||
}, [isEditMode, handleBack, handleSave, quote, isSaving]);
|
||||
|
||||
// IntegratedDetailTemplate 사용
|
||||
return (
|
||||
<QuoteRegistrationV2
|
||||
mode="view"
|
||||
onBack={handleBack}
|
||||
initialData={quote}
|
||||
<IntegratedDetailTemplate
|
||||
config={dynamicConfig}
|
||||
mode={isEditMode ? "edit" : "view"}
|
||||
initialData={quote || {}}
|
||||
itemId={quoteId}
|
||||
isLoading={isLoading}
|
||||
headerActions={customHeaderActions}
|
||||
renderView={() => renderFormContent()}
|
||||
renderForm={() => renderFormContent()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,149 +1,88 @@
|
||||
'use client';
|
||||
|
||||
import { useParams } from 'next/navigation';
|
||||
import { AccountDetail } from '@/components/settings/AccountManagement/AccountDetail';
|
||||
import type { Account } from '@/components/settings/AccountManagement/types';
|
||||
/**
|
||||
* 계좌 상세/수정 페이지 - IntegratedDetailTemplate 적용
|
||||
*/
|
||||
|
||||
// Mock 데이터 (API 연동 전 임시)
|
||||
const mockAccounts: Account[] = [
|
||||
{
|
||||
id: 1,
|
||||
bankCode: 'shinhan',
|
||||
bankName: '신한은행',
|
||||
accountNumber: '1234-1234-1234-1234',
|
||||
accountName: '운영계좌 1',
|
||||
accountHolder: '예금주1',
|
||||
status: 'active',
|
||||
isPrimary: true,
|
||||
createdAt: '2025-12-19T00:00:00.000Z',
|
||||
updatedAt: '2025-12-19T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
bankCode: 'kb',
|
||||
bankName: 'KB국민은행',
|
||||
accountNumber: '1234-1234-1234-1235',
|
||||
accountName: '운영계좌 2',
|
||||
accountHolder: '예금주2',
|
||||
status: 'inactive',
|
||||
isPrimary: false,
|
||||
createdAt: '2025-12-19T00:00:00.000Z',
|
||||
updatedAt: '2025-12-19T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
bankCode: 'woori',
|
||||
bankName: '우리은행',
|
||||
accountNumber: '1234-1234-1234-1236',
|
||||
accountName: '운영계좌 3',
|
||||
accountHolder: '예금주3',
|
||||
status: 'active',
|
||||
isPrimary: false,
|
||||
createdAt: '2025-12-19T00:00:00.000Z',
|
||||
updatedAt: '2025-12-19T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
bankCode: 'hana',
|
||||
bankName: '하나은행',
|
||||
accountNumber: '1234-1234-1234-1237',
|
||||
accountName: '운영계좌 4',
|
||||
accountHolder: '예금주4',
|
||||
status: 'inactive',
|
||||
isPrimary: false,
|
||||
createdAt: '2025-12-19T00:00:00.000Z',
|
||||
updatedAt: '2025-12-19T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
bankCode: 'nh',
|
||||
bankName: 'NH농협은행',
|
||||
accountNumber: '1234-1234-1234-1238',
|
||||
accountName: '운영계좌 5',
|
||||
accountHolder: '예금주5',
|
||||
status: 'active',
|
||||
isPrimary: false,
|
||||
createdAt: '2025-12-19T00:00:00.000Z',
|
||||
updatedAt: '2025-12-19T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
bankCode: 'ibk',
|
||||
bankName: 'IBK기업은행',
|
||||
accountNumber: '1234-1234-1234-1239',
|
||||
accountName: '운영계좌 6',
|
||||
accountHolder: '예금주6',
|
||||
status: 'inactive',
|
||||
isPrimary: false,
|
||||
createdAt: '2025-12-19T00:00:00.000Z',
|
||||
updatedAt: '2025-12-19T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
bankCode: 'shinhan',
|
||||
bankName: '신한은행',
|
||||
accountNumber: '1234-1234-1234-1240',
|
||||
accountName: '운영계좌 7',
|
||||
accountHolder: '예금주7',
|
||||
status: 'active',
|
||||
isPrimary: false,
|
||||
createdAt: '2025-12-19T00:00:00.000Z',
|
||||
updatedAt: '2025-12-19T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 8,
|
||||
bankCode: 'kb',
|
||||
bankName: 'KB국민은행',
|
||||
accountNumber: '1234-1234-1234-1241',
|
||||
accountName: '운영계좌 8',
|
||||
accountHolder: '예금주8',
|
||||
status: 'inactive',
|
||||
isPrimary: false,
|
||||
createdAt: '2025-12-19T00:00:00.000Z',
|
||||
updatedAt: '2025-12-19T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 9,
|
||||
bankCode: 'woori',
|
||||
bankName: '우리은행',
|
||||
accountNumber: '1234-1234-1234-1242',
|
||||
accountName: '운영계좌 9',
|
||||
accountHolder: '예금주9',
|
||||
status: 'active',
|
||||
isPrimary: false,
|
||||
createdAt: '2025-12-19T00:00:00.000Z',
|
||||
updatedAt: '2025-12-19T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 10,
|
||||
bankCode: 'hana',
|
||||
bankName: '하나은행',
|
||||
accountNumber: '1234-1234-1234-1243',
|
||||
accountName: '운영계좌 10',
|
||||
accountHolder: '예금주10',
|
||||
status: 'inactive',
|
||||
isPrimary: false,
|
||||
createdAt: '2025-12-19T00:00:00.000Z',
|
||||
updatedAt: '2025-12-19T00:00:00.000Z',
|
||||
},
|
||||
];
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useParams, useSearchParams } from 'next/navigation';
|
||||
import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate';
|
||||
import { accountConfig } from '@/components/settings/AccountManagement/accountConfig';
|
||||
import {
|
||||
getBankAccount,
|
||||
updateBankAccount,
|
||||
deleteBankAccount,
|
||||
} from '@/components/settings/AccountManagement/actions';
|
||||
import type { Account, AccountFormData } from '@/components/settings/AccountManagement/types';
|
||||
import type { DetailMode } from '@/components/templates/IntegratedDetailTemplate';
|
||||
|
||||
export default function AccountDetailPage() {
|
||||
const params = useParams();
|
||||
const searchParams = useSearchParams();
|
||||
const accountId = Number(params.id);
|
||||
|
||||
// Mock: 계좌 조회
|
||||
const account = mockAccounts.find(a => a.id === accountId);
|
||||
const [account, setAccount] = useState<Account | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
if (!account) {
|
||||
// URL에서 mode 파라미터 확인 (?mode=edit)
|
||||
const urlMode = searchParams.get('mode');
|
||||
const initialMode: DetailMode = urlMode === 'edit' ? 'edit' : 'view';
|
||||
|
||||
// 데이터 로드
|
||||
useEffect(() => {
|
||||
async function loadAccount() {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const result = await getBankAccount(accountId);
|
||||
if (result.success && result.data) {
|
||||
setAccount(result.data);
|
||||
} else {
|
||||
setError(result.error || '계좌를 찾을 수 없습니다.');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load account:', err);
|
||||
setError('계좌 조회 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
loadAccount();
|
||||
}, [accountId]);
|
||||
|
||||
// 수정 핸들러
|
||||
const handleSubmit = async (data: Record<string, unknown>) => {
|
||||
const result = await updateBankAccount(accountId, data as Partial<AccountFormData>);
|
||||
return { success: result.success, error: result.error };
|
||||
};
|
||||
|
||||
// 삭제 핸들러
|
||||
const handleDelete = async () => {
|
||||
const result = await deleteBankAccount(accountId);
|
||||
return { success: result.success, error: result.error };
|
||||
};
|
||||
|
||||
// 에러 상태
|
||||
if (error && !isLoading) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
계좌를 찾을 수 없습니다.
|
||||
{error}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <AccountDetail account={account} mode="view" />;
|
||||
}
|
||||
return (
|
||||
<IntegratedDetailTemplate
|
||||
config={accountConfig}
|
||||
mode={initialMode}
|
||||
initialData={account || undefined}
|
||||
itemId={accountId}
|
||||
isLoading={isLoading}
|
||||
onSubmit={handleSubmit}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
46
src/app/[locale]/(protected)/settings/accounts/loading.tsx
Normal file
46
src/app/[locale]/(protected)/settings/accounts/loading.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
|
||||
/**
|
||||
* 계좌관리 페이지 로딩 UI (Skeleton)
|
||||
*
|
||||
* 페이지 전환 시 스피너 대신 Skeleton으로 일관된 UX 제공
|
||||
* Note: Server Component이므로 lucide 아이콘 직접 사용 불가
|
||||
*/
|
||||
export default function AccountsLoading() {
|
||||
return (
|
||||
<div className="p-3 md:p-6 space-y-6">
|
||||
{/* 헤더 Skeleton */}
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-8 w-32" />
|
||||
<Skeleton className="h-4 w-48" />
|
||||
</div>
|
||||
|
||||
{/* 카드 Skeleton */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Skeleton className="h-5 w-24" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{[1, 2, 3, 4, 5, 6].map((i) => (
|
||||
<div key={i} className="space-y-2">
|
||||
<Skeleton className="h-4 w-20" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 버튼 영역 Skeleton */}
|
||||
<div className="flex items-center justify-between">
|
||||
<Skeleton className="h-10 w-24" />
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="h-10 w-20" />
|
||||
<Skeleton className="h-10 w-20" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,25 @@
|
||||
'use client';
|
||||
|
||||
import { AccountDetail } from '@/components/settings/AccountManagement/AccountDetail';
|
||||
/**
|
||||
* 계좌 등록 페이지 - IntegratedDetailTemplate 적용
|
||||
*/
|
||||
|
||||
import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate';
|
||||
import { accountConfig } from '@/components/settings/AccountManagement/accountConfig';
|
||||
import { createBankAccount } from '@/components/settings/AccountManagement/actions';
|
||||
import type { AccountFormData } from '@/components/settings/AccountManagement/types';
|
||||
|
||||
export default function NewAccountPage() {
|
||||
return <AccountDetail mode="create" />;
|
||||
}
|
||||
const handleSubmit = async (data: Record<string, unknown>) => {
|
||||
const result = await createBankAccount(data as AccountFormData);
|
||||
return { success: result.success, error: result.error };
|
||||
};
|
||||
|
||||
return (
|
||||
<IntegratedDetailTemplate
|
||||
config={accountConfig}
|
||||
mode="create"
|
||||
onSubmit={handleSubmit}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
|
||||
/**
|
||||
* 권한관리 페이지 로딩 UI (Skeleton)
|
||||
*
|
||||
* 페이지 전환 시 스피너 대신 Skeleton으로 일관된 UX 제공
|
||||
*/
|
||||
export default function PermissionsLoading() {
|
||||
return (
|
||||
<div className="p-3 md:p-6 space-y-6">
|
||||
{/* 헤더 Skeleton */}
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-8 w-32" />
|
||||
<Skeleton className="h-4 w-48" />
|
||||
</div>
|
||||
|
||||
{/* 검색/필터 영역 Skeleton */}
|
||||
<div className="flex items-center gap-4">
|
||||
<Skeleton className="h-10 w-64" />
|
||||
<Skeleton className="h-10 w-32" />
|
||||
</div>
|
||||
|
||||
{/* 테이블 Skeleton */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Skeleton className="h-5 w-24" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{/* 테이블 헤더 */}
|
||||
<div className="flex items-center gap-4 border-b pb-4 mb-4">
|
||||
<Skeleton className="h-4 w-32" />
|
||||
<Skeleton className="h-4 w-24" />
|
||||
<Skeleton className="h-4 w-24" />
|
||||
<Skeleton className="h-4 w-20" />
|
||||
</div>
|
||||
{/* 테이블 행들 */}
|
||||
{[1, 2, 3, 4, 5].map((i) => (
|
||||
<div key={i} className="flex items-center gap-4 py-3 border-b last:border-b-0">
|
||||
<Skeleton className="h-4 w-32" />
|
||||
<Skeleton className="h-4 w-24" />
|
||||
<Skeleton className="h-4 w-24" />
|
||||
<Skeleton className="h-8 w-20" />
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 페이지네이션 Skeleton */}
|
||||
<div className="flex items-center justify-between">
|
||||
<Skeleton className="h-4 w-32" />
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="h-8 w-8" />
|
||||
<Skeleton className="h-8 w-8" />
|
||||
<Skeleton className="h-8 w-8" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<Popup | null>(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 <ContentLoadingSpinner text="팝업 정보를 불러오는 중..." />;
|
||||
}
|
||||
|
||||
if (!popup) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<p className="text-muted-foreground">팝업을 찾을 수 없습니다.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <PopupForm mode="edit" initialData={popup} />;
|
||||
}
|
||||
return <ContentLoadingSpinner text="페이지 이동 중..." />;
|
||||
}
|
||||
|
||||
@@ -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<Popup | null>(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 <ContentLoadingSpinner text="팝업 정보를 불러오는 중..." />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PopupDetail
|
||||
popup={popup}
|
||||
onEdit={handleEdit}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
|
||||
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>팝업 삭제</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
"{popup.title}" 팝업을 삭제하시겠습니까?
|
||||
<br />
|
||||
<span className="text-destructive">
|
||||
삭제된 팝업 정보는 복구할 수 없습니다.
|
||||
</span>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>취소</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={confirmDelete}
|
||||
disabled={isDeleting}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
{isDeleting ? '삭제 중...' : '삭제'}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
return <PopupDetailClientV2 popupId={id} />;
|
||||
}
|
||||
|
||||
@@ -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 <PopupForm mode="create" />;
|
||||
return <PopupDetailClientV2 popupId="new" />;
|
||||
}
|
||||
|
||||
@@ -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('/')}`;
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 악성채권 추심관리 상세 페이지
|
||||
* IntegratedDetailTemplate 마이그레이션 완료 (2026-01-20)
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useMemo } from 'react';
|
||||
import { useDaumPostcode } from '@/hooks/useDaumPostcode';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { format } from 'date-fns';
|
||||
import { AlertTriangle, Plus, X, FileText, Receipt, CreditCard, Upload, Download, Trash2 } from 'lucide-react';
|
||||
import { Plus, X, FileText, Receipt, CreditCard, Upload, Download, Trash2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
@@ -28,14 +33,13 @@ import {
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { PageLayout } from '@/components/organisms/PageLayout';
|
||||
import { PageHeader } from '@/components/organisms/PageHeader';
|
||||
import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate';
|
||||
import { badDebtConfig } from './badDebtConfig';
|
||||
import { toast } from 'sonner';
|
||||
import type {
|
||||
BadDebtRecord,
|
||||
BadDebtMemo,
|
||||
Manager,
|
||||
AttachedFile,
|
||||
CollectionStatus,
|
||||
} from './types';
|
||||
import {
|
||||
@@ -130,12 +134,8 @@ export function BadDebtDetail({ mode, recordId, initialData }: BadDebtDetailProp
|
||||
}, []);
|
||||
|
||||
// 네비게이션 핸들러
|
||||
const handleBack = useCallback(() => {
|
||||
router.push('/ko/accounting/bad-debt-collection');
|
||||
}, [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(() => {
|
||||
@@ -331,31 +331,44 @@ export function BadDebtDetail({ mode, recordId, initialData }: BadDebtDetailProp
|
||||
setNewAdditionalFiles(prev => prev.filter((_, i) => i !== index));
|
||||
}, []);
|
||||
|
||||
// 헤더 버튼
|
||||
const headerActions = useMemo(() => {
|
||||
// 동적 config (mode에 따라 title 변경)
|
||||
const dynamicConfig = useMemo(() => {
|
||||
const titleMap: Record<string, string> = {
|
||||
new: '악성채권 등록',
|
||||
edit: '악성채권 수정',
|
||||
view: '악성채권 추심관리 상세',
|
||||
};
|
||||
return {
|
||||
...badDebtConfig,
|
||||
title: titleMap[mode] || badDebtConfig.title,
|
||||
};
|
||||
}, [mode]);
|
||||
|
||||
// 커스텀 헤더 액션 (저장 확인 다이얼로그 패턴 유지)
|
||||
const customHeaderActions = useMemo(() => {
|
||||
if (isViewMode) {
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
<>
|
||||
<Button variant="outline" className="text-red-500 border-red-200 hover:bg-red-50" onClick={handleDelete} disabled={isLoading}>
|
||||
{isLoading ? '처리중...' : '삭제'}
|
||||
</Button>
|
||||
<Button onClick={handleEdit} className="bg-blue-500 hover:bg-blue-600" disabled={isLoading}>
|
||||
수정
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
<>
|
||||
<Button variant="outline" onClick={handleCancel} disabled={isLoading}>
|
||||
취소
|
||||
</Button>
|
||||
<Button onClick={handleSave} className="bg-blue-500 hover:bg-blue-600" disabled={isLoading}>
|
||||
{isLoading ? '처리중...' : (isNewMode ? '등록' : '저장')}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}, [isViewMode, isNewMode, isLoading, handleDelete, handleEdit, handleCancel, handleSave]);
|
||||
}, [isViewMode, isNewMode, isLoading, handleDelete, handleEdit, handleCancel, handleSave, mode]);
|
||||
|
||||
// 입력 필드 렌더링 헬퍼
|
||||
const renderField = (
|
||||
@@ -387,17 +400,9 @@ export function BadDebtDetail({ mode, recordId, initialData }: BadDebtDetailProp
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
<PageHeader
|
||||
title="악성채권 추심관리 상세"
|
||||
description="추심 대상 업체 정보를 표시"
|
||||
icon={AlertTriangle}
|
||||
actions={headerActions}
|
||||
onBack={handleBack}
|
||||
/>
|
||||
|
||||
<div className="space-y-6">
|
||||
// 폼 콘텐츠 렌더링
|
||||
const renderFormContent = useCallback(() => (
|
||||
<div className="space-y-6">
|
||||
{/* 기본 정보 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
@@ -956,6 +961,40 @@ export function BadDebtDetail({ mode, recordId, initialData }: BadDebtDetailProp
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
), [
|
||||
formData,
|
||||
isViewMode,
|
||||
isNewMode,
|
||||
newMemo,
|
||||
newBusinessRegistrationFile,
|
||||
newTaxInvoiceFile,
|
||||
newAdditionalFiles,
|
||||
handleChange,
|
||||
handleAddMemo,
|
||||
handleDeleteMemo,
|
||||
handleManagerChange,
|
||||
handleBillStatus,
|
||||
handleReceivablesStatus,
|
||||
handleFileDownload,
|
||||
handleDeleteExistingFile,
|
||||
handleAddAdditionalFile,
|
||||
handleRemoveNewAdditionalFile,
|
||||
openPostcode,
|
||||
renderField,
|
||||
]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<IntegratedDetailTemplate
|
||||
config={dynamicConfig}
|
||||
mode={isNewMode ? 'create' : (isViewMode ? 'view' : 'edit')}
|
||||
initialData={formData}
|
||||
itemId={recordId}
|
||||
isLoading={isLoading}
|
||||
headerActions={customHeaderActions}
|
||||
renderView={() => renderFormContent()}
|
||||
renderForm={() => renderFormContent()}
|
||||
/>
|
||||
|
||||
{/* 삭제 확인 다이얼로그 */}
|
||||
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
|
||||
@@ -1000,6 +1039,6 @@ export function BadDebtDetail({ mode, recordId, initialData }: BadDebtDetailProp
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</PageLayout>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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<DetailMode>(() => {
|
||||
if (isNewMode) return 'new';
|
||||
if (initialMode) return initialMode;
|
||||
if (modeFromQuery === 'edit') return 'edit';
|
||||
return 'view';
|
||||
});
|
||||
|
||||
const [recordData, setRecordData] = useState<BadDebtRecord | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(!isNewMode);
|
||||
const [error, setError] = useState<string | null>(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 <ContentLoadingSpinner text="악성채권 정보를 불러오는 중..." />;
|
||||
}
|
||||
|
||||
// 에러 발생 (view/edit 모드에서)
|
||||
if (error && !isNewMode) {
|
||||
return (
|
||||
<ErrorCard
|
||||
type="network"
|
||||
title="악성채권 정보를 불러올 수 없습니다"
|
||||
description={error}
|
||||
tips={[
|
||||
'해당 악성채권이 존재하는지 확인해주세요',
|
||||
'인터넷 연결 상태를 확인해주세요',
|
||||
'잠시 후 다시 시도해주세요',
|
||||
]}
|
||||
homeButtonLabel="목록으로 이동"
|
||||
homeButtonHref={BASE_PATH}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// 등록 모드
|
||||
if (mode === 'new') {
|
||||
return <BadDebtDetail mode="new" />;
|
||||
}
|
||||
|
||||
// 수정 모드
|
||||
if (mode === 'edit' && recordData) {
|
||||
return <BadDebtDetail mode="edit" recordId={recordId} initialData={recordData} />;
|
||||
}
|
||||
|
||||
// 상세 보기 모드
|
||||
if (mode === 'view' && recordData) {
|
||||
return <BadDebtDetail mode="view" recordId={recordId} initialData={recordData} />;
|
||||
}
|
||||
|
||||
// 데이터 없음 (should not reach here)
|
||||
return (
|
||||
<ErrorCard
|
||||
type="not-found"
|
||||
title="악성채권을 찾을 수 없습니다"
|
||||
description="요청하신 악성채권 정보가 존재하지 않습니다."
|
||||
homeButtonLabel="목록으로 이동"
|
||||
homeButtonHref={BASE_PATH}
|
||||
/>
|
||||
);
|
||||
}
|
||||
34
src/components/accounting/BadDebtCollection/badDebtConfig.ts
Normal file
34
src/components/accounting/BadDebtCollection/badDebtConfig.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { AlertTriangle } from 'lucide-react';
|
||||
import type { DetailConfig } from '@/components/templates/IntegratedDetailTemplate/types';
|
||||
|
||||
/**
|
||||
* 악성채권 추심관리 상세 페이지 Config
|
||||
*
|
||||
* 참고: 이 config는 타이틀/버튼 영역만 정의
|
||||
* 폼 내용은 renderView/renderForm에서 처리
|
||||
*
|
||||
* 특이사항:
|
||||
* - view/edit/new 모드 지원
|
||||
* - 저장 확인 다이얼로그 (커스텀 headerActions 사용)
|
||||
* - 파일 업로드/다운로드
|
||||
* - 메모 추가/삭제
|
||||
*/
|
||||
export const badDebtConfig: DetailConfig = {
|
||||
title: '악성채권 추심관리 상세',
|
||||
description: '추심 대상 업체 정보를 표시',
|
||||
icon: AlertTriangle,
|
||||
basePath: '/accounting/bad-debt-collection',
|
||||
fields: [], // renderView/renderForm 사용으로 필드 정의 불필요
|
||||
gridColumns: 2,
|
||||
actions: {
|
||||
showBack: true,
|
||||
showDelete: true,
|
||||
showEdit: true,
|
||||
backLabel: '목록',
|
||||
editLabel: '수정',
|
||||
deleteLabel: '삭제',
|
||||
cancelLabel: '취소',
|
||||
saveLabel: '저장',
|
||||
createLabel: '등록',
|
||||
},
|
||||
};
|
||||
@@ -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]
|
||||
);
|
||||
|
||||
@@ -2,15 +2,8 @@
|
||||
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import {
|
||||
FileText,
|
||||
Plus,
|
||||
X,
|
||||
Loader2,
|
||||
List,
|
||||
} from 'lucide-react';
|
||||
import { Plus, X } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ContentLoadingSpinner } from '@/components/ui/loading-spinner';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
@@ -21,16 +14,6 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -39,9 +22,9 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { PageLayout } from '@/components/organisms/PageLayout';
|
||||
import { PageHeader } from '@/components/organisms/PageHeader';
|
||||
import { toast } from 'sonner';
|
||||
import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate';
|
||||
import { billConfig } from './billConfig';
|
||||
import type { BillRecord, BillType, BillStatus, InstallmentRecord } from './types';
|
||||
import {
|
||||
BILL_TYPE_OPTIONS,
|
||||
@@ -68,8 +51,6 @@ export function BillDetail({ billId, mode }: BillDetailProps) {
|
||||
|
||||
// ===== 로딩 상태 =====
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
|
||||
// ===== 거래처 목록 =====
|
||||
const [clients, setClients] = useState<ClientOption[]>([]);
|
||||
@@ -84,7 +65,6 @@ export function BillDetail({ billId, mode }: BillDetailProps) {
|
||||
const [status, setStatus] = useState<BillStatus>('stored');
|
||||
const [note, setNote] = useState('');
|
||||
const [installments, setInstallments] = useState<InstallmentRecord[]>([]);
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||
|
||||
// ===== 거래처 목록 로드 =====
|
||||
useEffect(() => {
|
||||
@@ -127,36 +107,36 @@ export function BillDetail({ billId, mode }: BillDetailProps) {
|
||||
}, [billId, router]);
|
||||
|
||||
// ===== 저장 핸들러 =====
|
||||
const handleSave = useCallback(async () => {
|
||||
const handleSubmit = useCallback(async (): Promise<{ success: boolean; error?: string }> => {
|
||||
// 유효성 검사
|
||||
if (!billNumber.trim()) {
|
||||
toast.error('어음번호를 입력해주세요.');
|
||||
return;
|
||||
return { success: false, error: '어음번호를 입력해주세요.' };
|
||||
}
|
||||
if (!vendorId) {
|
||||
toast.error('거래처를 선택해주세요.');
|
||||
return;
|
||||
return { success: false, error: '거래처를 선택해주세요.' };
|
||||
}
|
||||
if (amount <= 0) {
|
||||
toast.error('금액을 입력해주세요.');
|
||||
return;
|
||||
return { success: false, error: '금액을 입력해주세요.' };
|
||||
}
|
||||
|
||||
// 차수 유효성 검사
|
||||
for (let i = 0; i < installments.length; i++) {
|
||||
const inst = installments[i];
|
||||
if (!inst.date) {
|
||||
toast.error(`차수 ${i + 1}번의 일자를 입력해주세요.`);
|
||||
return;
|
||||
const errorMsg = `차수 ${i + 1}번의 일자를 입력해주세요.`;
|
||||
toast.error(errorMsg);
|
||||
return { success: false, error: errorMsg };
|
||||
}
|
||||
if (inst.amount <= 0) {
|
||||
toast.error(`차수 ${i + 1}번의 금액을 입력해주세요.`);
|
||||
return;
|
||||
const errorMsg = `차수 ${i + 1}번의 금액을 입력해주세요.`;
|
||||
toast.error(errorMsg);
|
||||
return { success: false, error: errorMsg };
|
||||
}
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
|
||||
const billData: Partial<BillRecord> = {
|
||||
billNumber,
|
||||
billType,
|
||||
@@ -177,8 +157,6 @@ export function BillDetail({ billId, mode }: BillDetailProps) {
|
||||
result = await updateBill(billId, billData);
|
||||
}
|
||||
|
||||
setIsSaving(false);
|
||||
|
||||
if (result.success) {
|
||||
toast.success(isNewMode ? '어음이 등록되었습니다.' : '어음이 수정되었습니다.');
|
||||
if (isNewMode) {
|
||||
@@ -186,42 +164,24 @@ export function BillDetail({ billId, mode }: BillDetailProps) {
|
||||
} else {
|
||||
router.push(`/ko/accounting/bills/${billId}`);
|
||||
}
|
||||
return { success: true };
|
||||
} else {
|
||||
toast.error(result.error || '저장에 실패했습니다.');
|
||||
return { success: false, error: result.error || '저장에 실패했습니다.' };
|
||||
}
|
||||
}, [billId, billNumber, billType, vendorId, amount, issueDate, maturityDate, status, note, installments, router, isNewMode, clients]);
|
||||
|
||||
// ===== 취소 핸들러 =====
|
||||
const handleCancel = useCallback(() => {
|
||||
if (isNewMode) {
|
||||
router.push('/ko/accounting/bills');
|
||||
} else {
|
||||
router.push(`/ko/accounting/bills/${billId}`);
|
||||
}
|
||||
}, [router, billId, isNewMode]);
|
||||
|
||||
// ===== 목록으로 이동 =====
|
||||
const handleBack = useCallback(() => {
|
||||
router.push('/ko/accounting/bills');
|
||||
}, [router]);
|
||||
|
||||
// ===== 수정 모드로 이동 =====
|
||||
const handleEdit = useCallback(() => {
|
||||
router.push(`/ko/accounting/bills/${billId}?mode=edit`);
|
||||
}, [router, billId]);
|
||||
|
||||
// ===== 삭제 핸들러 =====
|
||||
const handleDelete = useCallback(async () => {
|
||||
setIsDeleting(true);
|
||||
const handleDelete = useCallback(async (): Promise<{ success: boolean; error?: string }> => {
|
||||
const result = await deleteBill(billId);
|
||||
setIsDeleting(false);
|
||||
setShowDeleteDialog(false);
|
||||
|
||||
if (result.success) {
|
||||
toast.success('어음이 삭제되었습니다.');
|
||||
router.push('/ko/accounting/bills');
|
||||
return { success: true };
|
||||
} else {
|
||||
toast.error(result.error || '삭제에 실패했습니다.');
|
||||
return { success: false, error: result.error || '삭제에 실패했습니다.' };
|
||||
}
|
||||
}, [billId, router]);
|
||||
|
||||
@@ -251,60 +211,9 @@ export function BillDetail({ billId, mode }: BillDetailProps) {
|
||||
// ===== 상태 옵션 (구분에 따라 변경) =====
|
||||
const statusOptions = getBillStatusOptions(billType);
|
||||
|
||||
// ===== 로딩 중 =====
|
||||
if (isLoading) {
|
||||
return (
|
||||
<PageLayout>
|
||||
<ContentLoadingSpinner text="어음 정보를 불러오는 중..." />
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
{/* 페이지 헤더 */}
|
||||
<PageHeader
|
||||
title={isNewMode ? '어음 등록' : isViewMode ? '어음 상세' : '어음 수정'}
|
||||
description="어음 및 수취어음 상세 현황을 관리합니다"
|
||||
icon={FileText}
|
||||
/>
|
||||
|
||||
{/* 헤더 액션 버튼 */}
|
||||
<div className="flex items-center justify-end gap-2 mb-6">
|
||||
{isViewMode ? (
|
||||
<>
|
||||
<Button variant="outline" onClick={handleBack}>
|
||||
<List className="h-4 w-4 mr-2" />
|
||||
목록
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="text-red-500 border-red-200 hover:bg-red-50"
|
||||
onClick={() => setShowDeleteDialog(true)}
|
||||
>
|
||||
삭제
|
||||
</Button>
|
||||
<Button onClick={handleEdit} className="bg-blue-500 hover:bg-blue-600">
|
||||
수정
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button variant="outline" onClick={handleCancel} disabled={isSaving}>
|
||||
취소
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={isSaving}
|
||||
className="bg-blue-500 hover:bg-blue-600"
|
||||
>
|
||||
{isSaving && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
|
||||
{isNewMode ? '등록' : '저장'}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
// ===== 폼 콘텐츠 렌더링 =====
|
||||
const renderFormContent = () => (
|
||||
<>
|
||||
{/* 기본 정보 섹션 */}
|
||||
<Card className="mb-6">
|
||||
<CardHeader>
|
||||
@@ -333,7 +242,7 @@ export function BillDetail({ billId, mode }: BillDetailProps) {
|
||||
</Label>
|
||||
<Select value={billType} onValueChange={(v) => setBillType(v as BillType)} disabled={isViewMode}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="선택 ▼" />
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{BILL_TYPE_OPTIONS.map((option) => (
|
||||
@@ -352,7 +261,7 @@ export function BillDetail({ billId, mode }: BillDetailProps) {
|
||||
</Label>
|
||||
<Select value={vendorId} onValueChange={setVendorId} disabled={isViewMode}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="선택 ▼" />
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{clients.map((client) => (
|
||||
@@ -410,7 +319,7 @@ export function BillDetail({ billId, mode }: BillDetailProps) {
|
||||
</Label>
|
||||
<Select value={status} onValueChange={(v) => setStatus(v as BillStatus)} disabled={isViewMode}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="선택 ▼" />
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{statusOptions.map((option) => (
|
||||
@@ -522,29 +431,31 @@ export function BillDetail({ billId, mode }: BillDetailProps) {
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
|
||||
{/* 삭제 확인 다이얼로그 */}
|
||||
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>어음 삭제</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
이 어음을 삭제하시겠습니까? 삭제된 데이터는 복구할 수 없습니다.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isDeleting}>취소</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleDelete}
|
||||
disabled={isDeleting}
|
||||
className="bg-red-500 hover:bg-red-600"
|
||||
>
|
||||
{isDeleting && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
|
||||
삭제
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</PageLayout>
|
||||
// ===== 템플릿 모드 및 동적 설정 =====
|
||||
const templateMode = isNewMode ? 'create' : mode;
|
||||
const dynamicConfig = {
|
||||
...billConfig,
|
||||
title: isNewMode ? '어음 등록' : '어음 상세',
|
||||
actions: {
|
||||
...billConfig.actions,
|
||||
submitLabel: isNewMode ? '등록' : '저장',
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<IntegratedDetailTemplate
|
||||
config={dynamicConfig}
|
||||
mode={templateMode}
|
||||
initialData={{}}
|
||||
itemId={billId}
|
||||
isLoading={isLoading}
|
||||
onSubmit={handleSubmit}
|
||||
onDelete={billId && billId !== 'new' ? handleDelete : undefined}
|
||||
renderView={() => renderFormContent()}
|
||||
renderForm={() => renderFormContent()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
32
src/components/accounting/BillManagement/billConfig.ts
Normal file
32
src/components/accounting/BillManagement/billConfig.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { FileText } from 'lucide-react';
|
||||
import type { DetailConfig } from '@/components/templates/IntegratedDetailTemplate/types';
|
||||
|
||||
/**
|
||||
* 어음 상세 페이지 Config
|
||||
*
|
||||
* 참고: 이 config는 타이틀/버튼 영역만 정의
|
||||
* 폼 내용은 기존 BillDetail의 renderView/renderForm에서 처리
|
||||
* (차수 관리 테이블 등 특수 기능 유지)
|
||||
*/
|
||||
export const billConfig: DetailConfig = {
|
||||
title: '어음 상세',
|
||||
description: '어음 및 수취어음 상세 현황을 관리합니다',
|
||||
icon: FileText,
|
||||
basePath: '/accounting/bills',
|
||||
fields: [], // renderView/renderForm 사용으로 필드 정의 불필요
|
||||
gridColumns: 2,
|
||||
actions: {
|
||||
showBack: true,
|
||||
showDelete: true,
|
||||
showEdit: true,
|
||||
backLabel: '목록',
|
||||
deleteLabel: '삭제',
|
||||
editLabel: '수정',
|
||||
submitLabel: '저장',
|
||||
cancelLabel: '취소',
|
||||
deleteConfirmMessage: {
|
||||
title: '어음 삭제',
|
||||
description: '이 어음을 삭제하시겠습니까? 삭제된 데이터는 복구할 수 없습니다.',
|
||||
},
|
||||
},
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user