feat(WEB): Phase 2-3 V2 마이그레이션 완료 및 ServerErrorPage 적용
Phase 2 완료 (4개): - 노무관리, 단가관리(건설), 입금, 출금 Phase 3 라우팅 구조 변경 완료 (22개): - 거래처(영업), 팝업관리, 공정관리, 게시판관리, 대손추심, Q&A - 현장관리, 실행내역, 견적관리, 견적(테스트) - 입찰관리, 이슈관리, 현장설명회, 견적서(건설) - 협력업체, 시공관리, 기성관리, 품목관리(건설) - 회계 도메인: 거래처, 매출, 세금계산서, 매입 신규 컴포넌트: - ErrorCard: 에러 페이지 UI 통일 - ServerErrorPage: V2 페이지 에러 처리 필수 - V2 Client 컴포넌트 및 Config 파일들 총 47개 상세 페이지 중 28개 완료, 19개 제외/불필요 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
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에서 동일 패턴 확인 (파일 업로드/목록), 삭제 확인 다이얼로그 패턴 추가
|
||||
File diff suppressed because it is too large
Load Diff
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,14 @@
|
||||
'use client';
|
||||
|
||||
import { use } from 'react';
|
||||
import DepositDetailClientV2 from '@/components/accounting/DepositManagement/DepositDetailClientV2';
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export default function DepositEditPage({ params }: PageProps) {
|
||||
const { id } = use(params);
|
||||
|
||||
return <DepositDetailClientV2 depositId={id} initialMode="edit" />;
|
||||
}
|
||||
@@ -1,13 +1,14 @@
|
||||
'use client';
|
||||
|
||||
import { useParams, useSearchParams } from 'next/navigation';
|
||||
import { DepositDetail } from '@/components/accounting/DepositManagement/DepositDetail';
|
||||
import { use } from 'react';
|
||||
import DepositDetailClientV2 from '@/components/accounting/DepositManagement/DepositDetailClientV2';
|
||||
|
||||
export default function DepositDetailPage() {
|
||||
const params = useParams();
|
||||
const searchParams = useSearchParams();
|
||||
const depositId = params.id as string;
|
||||
const mode = searchParams.get('mode') === 'edit' ? 'edit' : 'view';
|
||||
interface PageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
return <DepositDetail depositId={depositId} mode={mode} />;
|
||||
export default function DepositDetailPage({ params }: PageProps) {
|
||||
const { id } = use(params);
|
||||
|
||||
return <DepositDetailClientV2 depositId={id} initialMode="view" />;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import DepositDetailClientV2 from '@/components/accounting/DepositManagement/DepositDetailClientV2';
|
||||
|
||||
export default function DepositNewPage() {
|
||||
return <DepositDetailClientV2 initialMode="create" />;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
'use client';
|
||||
|
||||
import { use } from 'react';
|
||||
import WithdrawalDetailClientV2 from '@/components/accounting/WithdrawalManagement/WithdrawalDetailClientV2';
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export default function WithdrawalEditPage({ params }: PageProps) {
|
||||
const { id } = use(params);
|
||||
|
||||
return <WithdrawalDetailClientV2 withdrawalId={id} initialMode="edit" />;
|
||||
}
|
||||
@@ -1,13 +1,14 @@
|
||||
'use client';
|
||||
|
||||
import { useParams, useSearchParams } from 'next/navigation';
|
||||
import { WithdrawalDetail } from '@/components/accounting/WithdrawalManagement/WithdrawalDetail';
|
||||
import { use } from 'react';
|
||||
import WithdrawalDetailClientV2 from '@/components/accounting/WithdrawalManagement/WithdrawalDetailClientV2';
|
||||
|
||||
export default function WithdrawalDetailPage() {
|
||||
const params = useParams();
|
||||
const searchParams = useSearchParams();
|
||||
const withdrawalId = params.id as string;
|
||||
const mode = searchParams.get('mode') === 'edit' ? 'edit' : 'view';
|
||||
interface PageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
return <WithdrawalDetail withdrawalId={withdrawalId} mode={mode} />;
|
||||
export default function WithdrawalDetailPage({ params }: PageProps) {
|
||||
const { id } = use(params);
|
||||
|
||||
return <WithdrawalDetailClientV2 withdrawalId={id} initialMode="view" />;
|
||||
}
|
||||
@@ -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,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { use } from 'react';
|
||||
import PricingDetailClient from '@/components/business/construction/pricing-management/PricingDetailClient';
|
||||
import { PricingDetailClientV2 } from '@/components/business/construction/pricing-management';
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
@@ -10,5 +10,5 @@ interface PageProps {
|
||||
export default function PricingEditPage({ params }: PageProps) {
|
||||
const { id } = use(params);
|
||||
|
||||
return <PricingDetailClient id={id} mode="edit" />;
|
||||
return <PricingDetailClientV2 pricingId={id} initialMode="edit" />;
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { use } from 'react';
|
||||
import PricingDetailClient from '@/components/business/construction/pricing-management/PricingDetailClient';
|
||||
import { PricingDetailClientV2 } from '@/components/business/construction/pricing-management';
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
@@ -10,5 +10,5 @@ interface PageProps {
|
||||
export default function PricingDetailPage({ params }: PageProps) {
|
||||
const { id } = use(params);
|
||||
|
||||
return <PricingDetailClient id={id} mode="view" />;
|
||||
return <PricingDetailClientV2 pricingId={id} initialMode="view" />;
|
||||
}
|
||||
@@ -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,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,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,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,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,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,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,13 +1,15 @@
|
||||
/**
|
||||
* 견적 상세 테스트 페이지 (V2 UI)
|
||||
* 견적 상세/수정 테스트 페이지 (V2 UI 통합)
|
||||
*
|
||||
* 새로운 자동 견적 산출 UI 테스트용
|
||||
* 기존 견적 상세 페이지는 수정하지 않음
|
||||
* URL 패턴:
|
||||
* - /quote-management/test/[id] → 상세 보기 (view)
|
||||
* - /quote-management/test/[id]?mode=edit → 수정 모드 (edit)
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import { useRouter, useParams } from "next/navigation";
|
||||
import { useRouter, useParams, useSearchParams } from "next/navigation";
|
||||
import { useState, useEffect } from "react";
|
||||
import { QuoteRegistrationV2, QuoteFormDataV2, LocationItem } from "@/components/quotes/QuoteRegistrationV2";
|
||||
import { ContentLoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
@@ -84,10 +86,16 @@ const MOCK_DATA: QuoteFormDataV2 = {
|
||||
export default function QuoteTestDetailPage() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const searchParams = useSearchParams();
|
||||
const quoteId = params.id as string;
|
||||
|
||||
// V2 패턴: mode 체크
|
||||
const mode = searchParams.get("mode") || "view";
|
||||
const isEditMode = mode === "edit";
|
||||
|
||||
const [quote, setQuote] = useState<QuoteFormDataV2 | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// 테스트용 데이터 로드 시뮬레이션
|
||||
@@ -112,15 +120,42 @@ export default function QuoteTestDetailPage() {
|
||||
router.push("/sales/quote-management");
|
||||
};
|
||||
|
||||
// V2 패턴: 수정 저장 핸들러
|
||||
const handleSave = async (data: QuoteFormDataV2, saveType: "temporary" | "final") => {
|
||||
setIsSaving(true);
|
||||
try {
|
||||
// TODO: API 연동 시 실제 저장 로직 구현
|
||||
console.log("[테스트] 수정 데이터:", data);
|
||||
console.log("[테스트] 저장 타입:", saveType);
|
||||
|
||||
// 테스트용 지연
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
|
||||
toast.success(`[테스트] ${saveType === "temporary" ? "임시" : "최종"} 저장 완료`);
|
||||
|
||||
// 저장 후 상세 페이지로 이동
|
||||
if (saveType === "final") {
|
||||
router.push(`/sales/quote-management/test/${quoteId}`);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error("저장 중 오류가 발생했습니다.");
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return <ContentLoadingSpinner text="견적 정보를 불러오는 중..." />;
|
||||
}
|
||||
|
||||
// V2 패턴: mode에 따라 view/edit 렌더링
|
||||
return (
|
||||
<QuoteRegistrationV2
|
||||
mode="view"
|
||||
mode={isEditMode ? "edit" : "view"}
|
||||
onBack={handleBack}
|
||||
onSave={isEditMode ? handleSave : undefined}
|
||||
initialData={quote}
|
||||
isLoading={isSaving}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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('/')}`;
|
||||
|
||||
@@ -135,7 +135,7 @@ export function BadDebtDetail({ mode, recordId, initialData }: BadDebtDetailProp
|
||||
}, [router]);
|
||||
|
||||
const handleEdit = useCallback(() => {
|
||||
router.push(`/ko/accounting/bad-debt-collection/${recordId}/edit`);
|
||||
router.push(`/ko/accounting/bad-debt-collection/${recordId}?mode=edit`);
|
||||
}, [router, recordId]);
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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]
|
||||
);
|
||||
|
||||
@@ -333,7 +333,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 +352,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 +410,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) => (
|
||||
|
||||
@@ -288,7 +288,7 @@ export function DepositDetail({ depositId, mode }: DepositDetailProps) {
|
||||
</Label>
|
||||
<Select value={vendorId} onValueChange={setVendorId} disabled={isViewMode}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="선택 ▼" />
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{vendors.map((vendor) => (
|
||||
@@ -307,7 +307,7 @@ export function DepositDetail({ depositId, mode }: DepositDetailProps) {
|
||||
</Label>
|
||||
<Select value={depositType} onValueChange={(v) => setDepositType(v as DepositType)} disabled={isViewMode}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="선택 ▼" />
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{DEPOSIT_TYPE_SELECTOR_OPTIONS.map((option) => (
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { toast } from 'sonner';
|
||||
import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate';
|
||||
import type { DetailMode } from '@/components/templates/IntegratedDetailTemplate/types';
|
||||
import { depositDetailConfig } from './depositDetailConfig';
|
||||
import type { DepositRecord } from './types';
|
||||
import {
|
||||
getDepositById,
|
||||
createDeposit,
|
||||
updateDeposit,
|
||||
deleteDeposit,
|
||||
} from './actions';
|
||||
|
||||
// ===== Props =====
|
||||
interface DepositDetailClientV2Props {
|
||||
depositId?: string;
|
||||
initialMode?: DetailMode;
|
||||
}
|
||||
|
||||
export default function DepositDetailClientV2({
|
||||
depositId,
|
||||
initialMode = 'view',
|
||||
}: DepositDetailClientV2Props) {
|
||||
const router = useRouter();
|
||||
const [mode, setMode] = useState<DetailMode>(initialMode);
|
||||
const [deposit, setDeposit] = useState<DepositRecord | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(initialMode !== 'create');
|
||||
|
||||
// ===== 데이터 로드 =====
|
||||
useEffect(() => {
|
||||
const loadDeposit = async () => {
|
||||
if (depositId && initialMode !== 'create') {
|
||||
setIsLoading(true);
|
||||
const result = await getDepositById(depositId);
|
||||
if (result.success && result.data) {
|
||||
setDeposit(result.data);
|
||||
} else {
|
||||
toast.error(result.error || '입금 내역을 불러오는데 실패했습니다.');
|
||||
}
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
loadDeposit();
|
||||
}, [depositId, initialMode]);
|
||||
|
||||
// ===== 저장/등록 핸들러 =====
|
||||
const handleSubmit = useCallback(
|
||||
async (formData: Record<string, unknown>): Promise<{ success: boolean; error?: string }> => {
|
||||
const submitData = depositDetailConfig.transformSubmitData?.(formData) || formData;
|
||||
|
||||
if (!submitData.vendorId) {
|
||||
toast.error('거래처를 선택해주세요.');
|
||||
return { success: false, error: '거래처를 선택해주세요.' };
|
||||
}
|
||||
if (submitData.depositType === 'unset') {
|
||||
toast.error('입금 유형을 선택해주세요.');
|
||||
return { success: false, error: '입금 유형을 선택해주세요.' };
|
||||
}
|
||||
|
||||
const result =
|
||||
mode === 'create'
|
||||
? await createDeposit(submitData as Partial<DepositRecord>)
|
||||
: await updateDeposit(depositId!, submitData as Partial<DepositRecord>);
|
||||
|
||||
if (result.success) {
|
||||
toast.success(mode === 'create' ? '입금 내역이 등록되었습니다.' : '입금 내역이 수정되었습니다.');
|
||||
router.push('/ko/accounting/deposits');
|
||||
return { success: true };
|
||||
} else {
|
||||
toast.error(result.error || '저장에 실패했습니다.');
|
||||
return { success: false, error: result.error };
|
||||
}
|
||||
},
|
||||
[mode, depositId, router]
|
||||
);
|
||||
|
||||
// ===== 삭제 핸들러 =====
|
||||
const handleDelete = useCallback(async (): Promise<{ success: boolean; error?: string }> => {
|
||||
if (!depositId) return { success: false, error: 'ID가 없습니다.' };
|
||||
|
||||
const result = await deleteDeposit(depositId);
|
||||
if (result.success) {
|
||||
toast.success('입금 내역이 삭제되었습니다.');
|
||||
router.push('/ko/accounting/deposits');
|
||||
return { success: true };
|
||||
} else {
|
||||
toast.error(result.error || '삭제에 실패했습니다.');
|
||||
return { success: false, error: result.error };
|
||||
}
|
||||
}, [depositId, router]);
|
||||
|
||||
// ===== 모드 변경 핸들러 =====
|
||||
const handleModeChange = useCallback(
|
||||
(newMode: DetailMode) => {
|
||||
if (newMode === 'edit' && depositId) {
|
||||
router.push(`/ko/accounting/deposits/${depositId}/edit`);
|
||||
} else {
|
||||
setMode(newMode);
|
||||
}
|
||||
},
|
||||
[depositId, router]
|
||||
);
|
||||
|
||||
return (
|
||||
<IntegratedDetailTemplate
|
||||
config={depositDetailConfig as Parameters<typeof IntegratedDetailTemplate>[0]['config']}
|
||||
mode={mode}
|
||||
initialData={deposit as unknown as Record<string, unknown> | undefined}
|
||||
itemId={depositId}
|
||||
isLoading={isLoading}
|
||||
onSubmit={handleSubmit}
|
||||
onDelete={handleDelete}
|
||||
onModeChange={handleModeChange}
|
||||
buttonPosition="top"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import { Banknote } from 'lucide-react';
|
||||
import type { DetailConfig, FieldDefinition } from '@/components/templates/IntegratedDetailTemplate/types';
|
||||
import type { DepositRecord } from './types';
|
||||
import { DEPOSIT_TYPE_SELECTOR_OPTIONS } from './types';
|
||||
import { getVendors } from './actions';
|
||||
|
||||
// ===== 필드 정의 =====
|
||||
const fields: FieldDefinition[] = [
|
||||
// 입금일 (readonly)
|
||||
{
|
||||
key: 'depositDate',
|
||||
label: '입금일',
|
||||
type: 'text',
|
||||
readonly: true,
|
||||
placeholder: '-',
|
||||
},
|
||||
// 입금계좌 (readonly)
|
||||
{
|
||||
key: 'accountName',
|
||||
label: '입금계좌',
|
||||
type: 'text',
|
||||
readonly: true,
|
||||
placeholder: '-',
|
||||
},
|
||||
// 입금자명 (readonly)
|
||||
{
|
||||
key: 'depositorName',
|
||||
label: '입금자명',
|
||||
type: 'text',
|
||||
readonly: true,
|
||||
placeholder: '-',
|
||||
},
|
||||
// 입금금액 (readonly)
|
||||
{
|
||||
key: 'depositAmount',
|
||||
label: '입금금액',
|
||||
type: 'text',
|
||||
readonly: true,
|
||||
placeholder: '-',
|
||||
},
|
||||
// 적요 (editable)
|
||||
{
|
||||
key: 'note',
|
||||
label: '적요',
|
||||
type: 'text',
|
||||
placeholder: '적요를 입력해주세요',
|
||||
gridSpan: 2,
|
||||
disabled: (mode) => mode === 'view',
|
||||
},
|
||||
// 거래처 (editable, required)
|
||||
{
|
||||
key: 'vendorId',
|
||||
label: '거래처',
|
||||
type: 'select',
|
||||
required: true,
|
||||
placeholder: '선택',
|
||||
fetchOptions: async () => {
|
||||
const result = await getVendors();
|
||||
if (result.success) {
|
||||
return result.data.map((v) => ({
|
||||
value: v.id,
|
||||
label: v.name,
|
||||
}));
|
||||
}
|
||||
return [];
|
||||
},
|
||||
disabled: (mode) => mode === 'view',
|
||||
},
|
||||
// 입금 유형 (editable, required)
|
||||
{
|
||||
key: 'depositType',
|
||||
label: '입금 유형',
|
||||
type: 'select',
|
||||
required: true,
|
||||
placeholder: '선택',
|
||||
options: DEPOSIT_TYPE_SELECTOR_OPTIONS.map((opt) => ({
|
||||
value: opt.value,
|
||||
label: opt.label,
|
||||
})),
|
||||
disabled: (mode) => mode === 'view',
|
||||
},
|
||||
];
|
||||
|
||||
// ===== Config 정의 =====
|
||||
export const depositDetailConfig: DetailConfig = {
|
||||
title: '입금',
|
||||
description: '입금 상세 내역을 등록합니다',
|
||||
icon: Banknote,
|
||||
basePath: '/accounting/deposits',
|
||||
fields,
|
||||
gridColumns: 2,
|
||||
actions: {
|
||||
showBack: true,
|
||||
showDelete: true,
|
||||
showEdit: true,
|
||||
backLabel: '목록',
|
||||
deleteLabel: '삭제',
|
||||
editLabel: '수정',
|
||||
deleteConfirmMessage: {
|
||||
title: '입금 삭제',
|
||||
description: '이 입금 내역을 삭제하시겠습니까? 삭제된 데이터는 복구할 수 없습니다.',
|
||||
},
|
||||
},
|
||||
transformInitialData: (data: Record<string, unknown>): Record<string, unknown> => {
|
||||
const record = data as unknown as DepositRecord;
|
||||
return {
|
||||
depositDate: record.depositDate || '',
|
||||
accountName: record.accountName || '',
|
||||
depositorName: record.depositorName || '',
|
||||
depositAmount: record.depositAmount ? record.depositAmount.toLocaleString() : '0',
|
||||
note: record.note || '',
|
||||
vendorId: record.vendorId || '',
|
||||
depositType: record.depositType || 'unset',
|
||||
};
|
||||
},
|
||||
transformSubmitData: (formData: Record<string, unknown>): Partial<DepositRecord> => {
|
||||
return {
|
||||
note: formData.note as string,
|
||||
vendorId: formData.vendorId as string,
|
||||
depositType: formData.depositType as DepositRecord['depositType'],
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -400,7 +400,7 @@ export function SalesDetail({ mode, salesId }: SalesDetailProps) {
|
||||
<Label htmlFor="salesType">매출 유형</Label>
|
||||
<Select value={salesType} onValueChange={(v) => setSalesType(v as SalesType)} disabled={isViewMode}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="선택 ▼" />
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SALES_TYPE_OPTIONS.filter(o => o.value !== 'all').map((option) => (
|
||||
|
||||
@@ -288,7 +288,7 @@ export function WithdrawalDetail({ withdrawalId, mode }: WithdrawalDetailProps)
|
||||
</Label>
|
||||
<Select value={vendorId} onValueChange={setVendorId} disabled={isViewMode}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="선택 ▼" />
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{vendors.map((vendor) => (
|
||||
@@ -307,7 +307,7 @@ export function WithdrawalDetail({ withdrawalId, mode }: WithdrawalDetailProps)
|
||||
</Label>
|
||||
<Select value={withdrawalType} onValueChange={(v) => setWithdrawalType(v as WithdrawalType)} disabled={isViewMode}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="선택 ▼" />
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{WITHDRAWAL_TYPE_SELECTOR_OPTIONS.map((option) => (
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { toast } from 'sonner';
|
||||
import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate';
|
||||
import type { DetailMode } from '@/components/templates/IntegratedDetailTemplate/types';
|
||||
import { withdrawalDetailConfig } from './withdrawalDetailConfig';
|
||||
import type { WithdrawalRecord } from './types';
|
||||
import {
|
||||
getWithdrawalById,
|
||||
createWithdrawal,
|
||||
updateWithdrawal,
|
||||
deleteWithdrawal,
|
||||
} from './actions';
|
||||
|
||||
// ===== Props =====
|
||||
interface WithdrawalDetailClientV2Props {
|
||||
withdrawalId?: string;
|
||||
initialMode?: DetailMode;
|
||||
}
|
||||
|
||||
export default function WithdrawalDetailClientV2({
|
||||
withdrawalId,
|
||||
initialMode = 'view',
|
||||
}: WithdrawalDetailClientV2Props) {
|
||||
const router = useRouter();
|
||||
const [mode, setMode] = useState<DetailMode>(initialMode);
|
||||
const [withdrawal, setWithdrawal] = useState<WithdrawalRecord | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(initialMode !== 'create');
|
||||
|
||||
// ===== 데이터 로드 =====
|
||||
useEffect(() => {
|
||||
const loadWithdrawal = async () => {
|
||||
if (withdrawalId && initialMode !== 'create') {
|
||||
setIsLoading(true);
|
||||
const result = await getWithdrawalById(withdrawalId);
|
||||
if (result.success && result.data) {
|
||||
setWithdrawal(result.data);
|
||||
} else {
|
||||
toast.error(result.error || '출금 내역을 불러오는데 실패했습니다.');
|
||||
}
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
loadWithdrawal();
|
||||
}, [withdrawalId, initialMode]);
|
||||
|
||||
// ===== 저장/등록 핸들러 =====
|
||||
const handleSubmit = useCallback(
|
||||
async (formData: Record<string, unknown>): Promise<{ success: boolean; error?: string }> => {
|
||||
const submitData = withdrawalDetailConfig.transformSubmitData?.(formData) || formData;
|
||||
|
||||
if (!submitData.vendorId) {
|
||||
toast.error('거래처를 선택해주세요.');
|
||||
return { success: false, error: '거래처를 선택해주세요.' };
|
||||
}
|
||||
if (submitData.withdrawalType === 'unset') {
|
||||
toast.error('출금 유형을 선택해주세요.');
|
||||
return { success: false, error: '출금 유형을 선택해주세요.' };
|
||||
}
|
||||
|
||||
const result =
|
||||
mode === 'create'
|
||||
? await createWithdrawal(submitData as Partial<WithdrawalRecord>)
|
||||
: await updateWithdrawal(withdrawalId!, submitData as Partial<WithdrawalRecord>);
|
||||
|
||||
if (result.success) {
|
||||
toast.success(mode === 'create' ? '출금 내역이 등록되었습니다.' : '출금 내역이 수정되었습니다.');
|
||||
router.push('/ko/accounting/withdrawals');
|
||||
return { success: true };
|
||||
} else {
|
||||
toast.error(result.error || '저장에 실패했습니다.');
|
||||
return { success: false, error: result.error };
|
||||
}
|
||||
},
|
||||
[mode, withdrawalId, router]
|
||||
);
|
||||
|
||||
// ===== 삭제 핸들러 =====
|
||||
const handleDelete = useCallback(async (): Promise<{ success: boolean; error?: string }> => {
|
||||
if (!withdrawalId) return { success: false, error: 'ID가 없습니다.' };
|
||||
|
||||
const result = await deleteWithdrawal(withdrawalId);
|
||||
if (result.success) {
|
||||
toast.success('출금 내역이 삭제되었습니다.');
|
||||
router.push('/ko/accounting/withdrawals');
|
||||
return { success: true };
|
||||
} else {
|
||||
toast.error(result.error || '삭제에 실패했습니다.');
|
||||
return { success: false, error: result.error };
|
||||
}
|
||||
}, [withdrawalId, router]);
|
||||
|
||||
// ===== 모드 변경 핸들러 =====
|
||||
const handleModeChange = useCallback(
|
||||
(newMode: DetailMode) => {
|
||||
if (newMode === 'edit' && withdrawalId) {
|
||||
router.push(`/ko/accounting/withdrawals/${withdrawalId}/edit`);
|
||||
} else {
|
||||
setMode(newMode);
|
||||
}
|
||||
},
|
||||
[withdrawalId, router]
|
||||
);
|
||||
|
||||
return (
|
||||
<IntegratedDetailTemplate
|
||||
config={withdrawalDetailConfig as Parameters<typeof IntegratedDetailTemplate>[0]['config']}
|
||||
mode={mode}
|
||||
initialData={withdrawal as unknown as Record<string, unknown> | undefined}
|
||||
itemId={withdrawalId}
|
||||
isLoading={isLoading}
|
||||
onSubmit={handleSubmit}
|
||||
onDelete={handleDelete}
|
||||
onModeChange={handleModeChange}
|
||||
buttonPosition="top"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import { Banknote } from 'lucide-react';
|
||||
import type { DetailConfig, FieldDefinition } from '@/components/templates/IntegratedDetailTemplate/types';
|
||||
import type { WithdrawalRecord } from './types';
|
||||
import { WITHDRAWAL_TYPE_SELECTOR_OPTIONS } from './types';
|
||||
import { getVendors } from './actions';
|
||||
|
||||
// ===== 필드 정의 =====
|
||||
const fields: FieldDefinition[] = [
|
||||
// 출금일 (readonly)
|
||||
{
|
||||
key: 'withdrawalDate',
|
||||
label: '출금일',
|
||||
type: 'text',
|
||||
readonly: true,
|
||||
placeholder: '-',
|
||||
},
|
||||
// 출금계좌 (readonly)
|
||||
{
|
||||
key: 'accountName',
|
||||
label: '출금계좌',
|
||||
type: 'text',
|
||||
readonly: true,
|
||||
placeholder: '-',
|
||||
},
|
||||
// 수취인명 (readonly)
|
||||
{
|
||||
key: 'recipientName',
|
||||
label: '수취인명',
|
||||
type: 'text',
|
||||
readonly: true,
|
||||
placeholder: '-',
|
||||
},
|
||||
// 출금금액 (readonly)
|
||||
{
|
||||
key: 'withdrawalAmount',
|
||||
label: '출금금액',
|
||||
type: 'text',
|
||||
readonly: true,
|
||||
placeholder: '-',
|
||||
},
|
||||
// 적요 (editable)
|
||||
{
|
||||
key: 'note',
|
||||
label: '적요',
|
||||
type: 'text',
|
||||
placeholder: '적요를 입력해주세요',
|
||||
gridSpan: 2,
|
||||
disabled: (mode) => mode === 'view',
|
||||
},
|
||||
// 거래처 (editable, required)
|
||||
{
|
||||
key: 'vendorId',
|
||||
label: '거래처',
|
||||
type: 'select',
|
||||
required: true,
|
||||
placeholder: '선택',
|
||||
fetchOptions: async () => {
|
||||
const result = await getVendors();
|
||||
if (result.success) {
|
||||
return result.data.map((v) => ({
|
||||
value: v.id,
|
||||
label: v.name,
|
||||
}));
|
||||
}
|
||||
return [];
|
||||
},
|
||||
disabled: (mode) => mode === 'view',
|
||||
},
|
||||
// 출금 유형 (editable, required)
|
||||
{
|
||||
key: 'withdrawalType',
|
||||
label: '출금 유형',
|
||||
type: 'select',
|
||||
required: true,
|
||||
placeholder: '선택',
|
||||
options: WITHDRAWAL_TYPE_SELECTOR_OPTIONS.map((opt) => ({
|
||||
value: opt.value,
|
||||
label: opt.label,
|
||||
})),
|
||||
disabled: (mode) => mode === 'view',
|
||||
},
|
||||
];
|
||||
|
||||
// ===== Config 정의 =====
|
||||
export const withdrawalDetailConfig: DetailConfig = {
|
||||
title: '출금',
|
||||
description: '출금 상세 내역을 등록합니다',
|
||||
icon: Banknote,
|
||||
basePath: '/accounting/withdrawals',
|
||||
fields,
|
||||
gridColumns: 2,
|
||||
actions: {
|
||||
showBack: true,
|
||||
showDelete: true,
|
||||
showEdit: true,
|
||||
backLabel: '목록',
|
||||
deleteLabel: '삭제',
|
||||
editLabel: '수정',
|
||||
deleteConfirmMessage: {
|
||||
title: '출금 삭제',
|
||||
description: '이 출금 내역을 삭제하시겠습니까? 삭제된 데이터는 복구할 수 없습니다.',
|
||||
},
|
||||
},
|
||||
transformInitialData: (data: Record<string, unknown>): Record<string, unknown> => {
|
||||
const record = data as unknown as WithdrawalRecord;
|
||||
return {
|
||||
withdrawalDate: record.withdrawalDate || '',
|
||||
accountName: record.accountName || '',
|
||||
recipientName: record.recipientName || '',
|
||||
withdrawalAmount: record.withdrawalAmount ? record.withdrawalAmount.toLocaleString() : '0',
|
||||
note: record.note || '',
|
||||
vendorId: record.vendorId || '',
|
||||
withdrawalType: record.withdrawalType || 'unset',
|
||||
};
|
||||
},
|
||||
transformSubmitData: (formData: Record<string, unknown>): Partial<WithdrawalRecord> => {
|
||||
return {
|
||||
note: formData.note as string,
|
||||
vendorId: formData.vendorId as string,
|
||||
withdrawalType: formData.withdrawalType as WithdrawalRecord['withdrawalType'],
|
||||
};
|
||||
},
|
||||
};
|
||||
329
src/components/board/BoardManagement/BoardDetailClientV2.tsx
Normal file
329
src/components/board/BoardManagement/BoardDetailClientV2.tsx
Normal file
@@ -0,0 +1,329 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 게시판관리 상세 클라이언트 컴포넌트 V2
|
||||
*
|
||||
* 라우팅 구조 변경: /[id], /[id]/edit, /new → /[id]?mode=view|edit, /new
|
||||
* 기존 BoardDetail, BoardForm 컴포넌트 활용
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { BoardDetail } from './BoardDetail';
|
||||
import { BoardForm } from './BoardForm';
|
||||
import { getBoardById, createBoard, updateBoard, deleteBoard } from './actions';
|
||||
import { forceRefreshMenus } from '@/lib/utils/menuRefresh';
|
||||
import type { Board, BoardFormData } from './types';
|
||||
import { ContentLoadingSpinner } from '@/components/ui/loading-spinner';
|
||||
import { ErrorCard } from '@/components/ui/error-card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
type DetailMode = 'view' | 'edit' | 'create';
|
||||
|
||||
interface BoardDetailClientV2Props {
|
||||
boardId?: string;
|
||||
initialMode?: DetailMode;
|
||||
}
|
||||
|
||||
const BASE_PATH = '/ko/board/board-management';
|
||||
|
||||
// 게시판 코드 생성 (타임스탬프 기반)
|
||||
const generateBoardCode = (): string => {
|
||||
const timestamp = Date.now().toString(36);
|
||||
const random = Math.random().toString(36).substring(2, 6);
|
||||
return `board_${timestamp}_${random}`;
|
||||
};
|
||||
|
||||
export function BoardDetailClientV2({ boardId, initialMode }: BoardDetailClientV2Props) {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
// URL 쿼리에서 모드 결정
|
||||
const modeFromQuery = searchParams.get('mode') as DetailMode | null;
|
||||
const isNewMode = !boardId || boardId === 'new';
|
||||
|
||||
const [mode, setMode] = useState<DetailMode>(() => {
|
||||
if (isNewMode) return 'create';
|
||||
if (initialMode) return initialMode;
|
||||
if (modeFromQuery === 'edit') return 'edit';
|
||||
return 'view';
|
||||
});
|
||||
|
||||
const [boardData, setBoardData] = useState<Board | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(!isNewMode);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
|
||||
// 데이터 로드
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
if (isNewMode) {
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const result = await getBoardById(boardId!);
|
||||
if (result.success && result.data) {
|
||||
setBoardData(result.data);
|
||||
} else {
|
||||
setError(result.error || '게시판 정보를 찾을 수 없습니다.');
|
||||
toast.error('게시판을 불러오는데 실패했습니다.');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('게시판 조회 실패:', err);
|
||||
setError('게시판 정보를 불러오는 중 오류가 발생했습니다.');
|
||||
toast.error('게시판을 불러오는데 실패했습니다.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadData();
|
||||
}, [boardId, isNewMode]);
|
||||
|
||||
// URL 쿼리 변경 감지
|
||||
useEffect(() => {
|
||||
if (!isNewMode && modeFromQuery === 'edit') {
|
||||
setMode('edit');
|
||||
} else if (!isNewMode && !modeFromQuery) {
|
||||
setMode('view');
|
||||
}
|
||||
}, [modeFromQuery, isNewMode]);
|
||||
|
||||
// 등록 핸들러
|
||||
const handleCreate = async (data: BoardFormData) => {
|
||||
setIsSubmitting(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const result = await createBoard({
|
||||
...data,
|
||||
boardCode: generateBoardCode(),
|
||||
});
|
||||
|
||||
if (result.success && result.data) {
|
||||
await forceRefreshMenus();
|
||||
toast.success('게시판이 등록되었습니다.');
|
||||
router.push(BASE_PATH);
|
||||
} else {
|
||||
setError(result.error || '게시판 등록에 실패했습니다.');
|
||||
toast.error(result.error || '게시판 등록에 실패했습니다.');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('게시판 등록 실패:', err);
|
||||
setError('게시판 등록 중 오류가 발생했습니다.');
|
||||
toast.error('게시판 등록 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 수정 핸들러
|
||||
const handleUpdate = async (data: BoardFormData) => {
|
||||
if (!boardData) return;
|
||||
|
||||
setIsSubmitting(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const result = await updateBoard(boardData.id, {
|
||||
...data,
|
||||
boardCode: boardData.boardCode,
|
||||
description: boardData.description,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
await forceRefreshMenus();
|
||||
toast.success('게시판이 수정되었습니다.');
|
||||
router.push(`${BASE_PATH}/${boardData.id}`);
|
||||
} else {
|
||||
setError(result.error || '게시판 수정에 실패했습니다.');
|
||||
toast.error(result.error || '게시판 수정에 실패했습니다.');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('게시판 수정 실패:', err);
|
||||
setError('게시판 수정 중 오류가 발생했습니다.');
|
||||
toast.error('게시판 수정 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 삭제 핸들러
|
||||
const handleDelete = () => {
|
||||
setDeleteDialogOpen(true);
|
||||
};
|
||||
|
||||
const confirmDelete = async () => {
|
||||
if (!boardData) return;
|
||||
|
||||
setIsDeleting(true);
|
||||
|
||||
try {
|
||||
const result = await deleteBoard(boardData.id);
|
||||
|
||||
if (result.success) {
|
||||
await forceRefreshMenus();
|
||||
toast.success('게시판이 삭제되었습니다.');
|
||||
router.push(BASE_PATH);
|
||||
} else {
|
||||
setError(result.error || '삭제에 실패했습니다.');
|
||||
toast.error(result.error || '삭제에 실패했습니다.');
|
||||
setDeleteDialogOpen(false);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('게시판 삭제 실패:', err);
|
||||
setError('게시판 삭제 중 오류가 발생했습니다.');
|
||||
toast.error('게시판 삭제 중 오류가 발생했습니다.');
|
||||
setDeleteDialogOpen(false);
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 수정 모드 전환
|
||||
const handleEdit = () => {
|
||||
router.push(`${BASE_PATH}/${boardId}?mode=edit`);
|
||||
};
|
||||
|
||||
// 로딩 중
|
||||
if (isLoading) {
|
||||
return <ContentLoadingSpinner text="게시판 정보를 불러오는 중..." />;
|
||||
}
|
||||
|
||||
// 에러 발생 (view/edit 모드에서)
|
||||
if (error && !isNewMode) {
|
||||
return (
|
||||
<ErrorCard
|
||||
type="network"
|
||||
title="게시판 정보를 불러올 수 없습니다"
|
||||
description={error}
|
||||
tips={[
|
||||
'해당 게시판이 존재하는지 확인해주세요',
|
||||
'인터넷 연결 상태를 확인해주세요',
|
||||
'잠시 후 다시 시도해주세요',
|
||||
]}
|
||||
homeButtonLabel="목록으로 이동"
|
||||
homeButtonHref={BASE_PATH}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// 등록 모드
|
||||
if (mode === 'create') {
|
||||
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={handleCreate} />
|
||||
{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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// 수정 모드
|
||||
if (mode === 'edit' && boardData) {
|
||||
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={boardData} onSubmit={handleUpdate} />
|
||||
{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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// 상세 보기 모드
|
||||
if (mode === 'view' && boardData) {
|
||||
return (
|
||||
<>
|
||||
<BoardDetail
|
||||
board={boardData}
|
||||
onEdit={handleEdit}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
|
||||
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>게시판 삭제</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
"{boardData.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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// 데이터 없음 (should not reach here)
|
||||
return (
|
||||
<ErrorCard
|
||||
type="not-found"
|
||||
title="게시판을 찾을 수 없습니다"
|
||||
description="요청하신 게시판 정보가 존재하지 않습니다."
|
||||
homeButtonLabel="목록으로 이동"
|
||||
homeButtonHref={BASE_PATH}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
'use client';
|
||||
|
||||
export { BoardDetailClientV2 } from './BoardDetailClientV2';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { ClipboardList, Edit, Trash2, Plus } from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* LaborDetailClientV2 - IntegratedDetailTemplate 기반 노임 상세/등록/수정
|
||||
*
|
||||
* 기존 LaborDetailClient를 IntegratedDetailTemplate으로 마이그레이션
|
||||
* - buttonPosition="top" 사용 (상단 버튼)
|
||||
* - 6개 필드: 노임번호, 구분, 최소M, 최대M, 노임단가, 상태
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
IntegratedDetailTemplate,
|
||||
type DetailMode,
|
||||
} from '@/components/templates/IntegratedDetailTemplate';
|
||||
import { laborDetailConfig } from './laborDetailConfig';
|
||||
import type { Labor, LaborFormData } from './types';
|
||||
import { getLabor, createLabor, updateLabor, deleteLabor } from './actions';
|
||||
|
||||
interface LaborDetailClientV2Props {
|
||||
laborId?: string;
|
||||
initialMode?: DetailMode;
|
||||
}
|
||||
|
||||
export default function LaborDetailClientV2({
|
||||
laborId,
|
||||
initialMode = 'view',
|
||||
}: LaborDetailClientV2Props) {
|
||||
const router = useRouter();
|
||||
const [labor, setLabor] = useState<Labor | undefined>(undefined);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [mode, setMode] = useState<DetailMode>(initialMode);
|
||||
|
||||
// 데이터 로드
|
||||
useEffect(() => {
|
||||
if (laborId && initialMode !== 'create') {
|
||||
const loadData = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const result = await getLabor(laborId);
|
||||
if (result.success && result.data) {
|
||||
setLabor(result.data);
|
||||
} else {
|
||||
toast.error(result.error || '노임 정보를 불러오는데 실패했습니다.');
|
||||
router.push('/ko/construction/order/base-info/labor');
|
||||
}
|
||||
} catch {
|
||||
toast.error('노임 정보를 불러오는데 실패했습니다.');
|
||||
router.push('/ko/construction/order/base-info/labor');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
loadData();
|
||||
}
|
||||
}, [laborId, initialMode, router]);
|
||||
|
||||
// 저장 핸들러
|
||||
const handleSubmit = useCallback(
|
||||
async (formData: Record<string, unknown>) => {
|
||||
try {
|
||||
const submitData = laborDetailConfig.transformSubmitData!(formData) as unknown as LaborFormData;
|
||||
|
||||
if (mode === 'create') {
|
||||
const result = await createLabor(submitData);
|
||||
if (result.success && result.data) {
|
||||
return { success: true };
|
||||
}
|
||||
return { success: false, error: result.error || '노임 등록에 실패했습니다.' };
|
||||
} else if (mode === 'edit' && laborId) {
|
||||
const result = await updateLabor(laborId, submitData);
|
||||
if (result.success) {
|
||||
return { success: true };
|
||||
}
|
||||
return { success: false, error: result.error || '노임 수정에 실패했습니다.' };
|
||||
}
|
||||
|
||||
return { success: false, error: '알 수 없는 오류가 발생했습니다.' };
|
||||
} catch {
|
||||
return { success: false, error: '저장 중 오류가 발생했습니다.' };
|
||||
}
|
||||
},
|
||||
[mode, laborId]
|
||||
);
|
||||
|
||||
// 삭제 핸들러
|
||||
const handleDelete = useCallback(async (id: string | number) => {
|
||||
try {
|
||||
const result = await deleteLabor(String(id));
|
||||
if (result.success) {
|
||||
return { success: true };
|
||||
}
|
||||
return { success: false, error: result.error || '노임 삭제에 실패했습니다.' };
|
||||
} catch {
|
||||
return { success: false, error: '삭제 중 오류가 발생했습니다.' };
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 모드 변경 핸들러
|
||||
const handleModeChange = useCallback((newMode: DetailMode) => {
|
||||
setMode(newMode);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<IntegratedDetailTemplate
|
||||
config={laborDetailConfig as Parameters<typeof IntegratedDetailTemplate>[0]['config']}
|
||||
mode={mode}
|
||||
initialData={labor as Record<string, unknown> | undefined}
|
||||
itemId={laborId}
|
||||
isLoading={isLoading}
|
||||
onSubmit={handleSubmit}
|
||||
onDelete={handleDelete}
|
||||
onModeChange={handleModeChange}
|
||||
buttonPosition="top"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Named export for backwards compatibility
|
||||
export { LaborDetailClientV2 };
|
||||
@@ -1,5 +1,7 @@
|
||||
export { default as LaborManagementClient } from './LaborManagementClient';
|
||||
export { default as LaborDetailClient } from './LaborDetailClient';
|
||||
export { default as LaborDetailClientV2 } from './LaborDetailClientV2';
|
||||
export { laborDetailConfig } from './laborDetailConfig';
|
||||
export * from './types';
|
||||
export * from './constants';
|
||||
export * from './actions';
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* 노임관리 상세 페이지 설정
|
||||
* IntegratedDetailTemplate용 config
|
||||
*/
|
||||
|
||||
import { Hammer } from 'lucide-react';
|
||||
import type { DetailConfig, FieldDefinition } from '@/components/templates/IntegratedDetailTemplate';
|
||||
import type { LaborFormData, LaborCategory, LaborStatus } from './types';
|
||||
import { CATEGORY_OPTIONS, STATUS_OPTIONS } from './constants';
|
||||
|
||||
// Select 옵션 변환 ('all' 제외)
|
||||
const categoryFieldOptions = CATEGORY_OPTIONS
|
||||
.filter((o) => o.value !== 'all')
|
||||
.map((o) => ({ value: o.value, label: o.label }));
|
||||
|
||||
const statusFieldOptions = STATUS_OPTIONS
|
||||
.filter((o) => o.value !== 'all')
|
||||
.map((o) => ({ value: o.value, label: o.label }));
|
||||
|
||||
// 필드 정의
|
||||
const fields: FieldDefinition[] = [
|
||||
{
|
||||
key: 'laborNumber',
|
||||
label: '노임번호',
|
||||
type: 'text',
|
||||
required: true,
|
||||
placeholder: '노임번호를 입력하세요',
|
||||
},
|
||||
{
|
||||
key: 'category',
|
||||
label: '구분',
|
||||
type: 'select',
|
||||
required: true,
|
||||
options: categoryFieldOptions,
|
||||
placeholder: '구분 선택',
|
||||
},
|
||||
{
|
||||
key: 'minM',
|
||||
label: '최소 M',
|
||||
type: 'number',
|
||||
placeholder: '0.00',
|
||||
helpText: '소수점 둘째자리까지 입력 가능',
|
||||
},
|
||||
{
|
||||
key: 'maxM',
|
||||
label: '최대 M',
|
||||
type: 'number',
|
||||
placeholder: '0.00',
|
||||
helpText: '소수점 둘째자리까지 입력 가능',
|
||||
},
|
||||
{
|
||||
key: 'laborPrice',
|
||||
label: '노임단가',
|
||||
type: 'number',
|
||||
placeholder: '0',
|
||||
formatValue: (value) => {
|
||||
if (value === null || value === undefined || value === '') return '-';
|
||||
return Number(value).toLocaleString('ko-KR');
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
label: '상태',
|
||||
type: 'select',
|
||||
required: true,
|
||||
options: statusFieldOptions,
|
||||
placeholder: '상태 선택',
|
||||
},
|
||||
];
|
||||
|
||||
// DetailConfig (Record<string, unknown> 제약 때문에 타입 캐스팅 필요)
|
||||
export const laborDetailConfig: DetailConfig = {
|
||||
title: '노임',
|
||||
description: '노임 정보를 등록하고 관리합니다.',
|
||||
icon: Hammer,
|
||||
basePath: '/construction/order/base-info/labor',
|
||||
fields,
|
||||
gridColumns: 2,
|
||||
actions: {
|
||||
showBack: true,
|
||||
showDelete: true,
|
||||
showEdit: true,
|
||||
backLabel: '목록',
|
||||
deleteLabel: '삭제',
|
||||
editLabel: '수정',
|
||||
submitLabel: undefined, // 모드에 따라 자동 결정 (등록/저장)
|
||||
cancelLabel: '취소',
|
||||
deleteConfirmMessage: {
|
||||
title: '노임 삭제',
|
||||
description: '이 노임을 삭제하시겠습니까? 이 작업은 취소할 수 없습니다.',
|
||||
},
|
||||
},
|
||||
transformInitialData: (data: Record<string, unknown>): Record<string, unknown> => ({
|
||||
laborNumber: data.laborNumber,
|
||||
category: data.category,
|
||||
minM: data.minM,
|
||||
maxM: data.maxM,
|
||||
laborPrice: data.laborPrice,
|
||||
status: data.status,
|
||||
}),
|
||||
transformSubmitData: (formData: Record<string, unknown>): Partial<LaborFormData> => ({
|
||||
laborNumber: formData.laborNumber as string,
|
||||
category: formData.category as LaborCategory,
|
||||
minM: Number(formData.minM) || 0,
|
||||
maxM: Number(formData.maxM) || 0,
|
||||
laborPrice: formData.laborPrice === '' || formData.laborPrice === null
|
||||
? null
|
||||
: Number(formData.laborPrice),
|
||||
status: formData.status as LaborStatus,
|
||||
}),
|
||||
};
|
||||
|
||||
export default laborDetailConfig;
|
||||
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* PricingDetailClientV2 - IntegratedDetailTemplate 기반 단가 상세/등록/수정
|
||||
*
|
||||
* 기존 PricingDetailClient를 IntegratedDetailTemplate으로 마이그레이션
|
||||
* - buttonPosition="top" 사용 (상단 버튼)
|
||||
* - 12개 필드: 단가번호, 품목유형, 카테고리명, 품목명, 규격, 무게, 단위, 구분, 거래처, 판매단가, 상태, 비고
|
||||
* - 대부분 필드 readonly, 거래처/판매단가/상태/비고만 edit/create 모드에서 수정 가능
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
IntegratedDetailTemplate,
|
||||
type DetailMode,
|
||||
} from '@/components/templates/IntegratedDetailTemplate';
|
||||
import { pricingDetailConfig } from './pricingDetailConfig';
|
||||
import type { Pricing, PricingFormData } from './types';
|
||||
import { getPricingDetail, createPricing, updatePricing, deletePricing } from './actions';
|
||||
|
||||
interface PricingDetailClientV2Props {
|
||||
pricingId?: string;
|
||||
initialMode?: DetailMode;
|
||||
}
|
||||
|
||||
export default function PricingDetailClientV2({
|
||||
pricingId,
|
||||
initialMode = 'view',
|
||||
}: PricingDetailClientV2Props) {
|
||||
const router = useRouter();
|
||||
const [pricing, setPricing] = useState<Pricing | undefined>(undefined);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [mode, setMode] = useState<DetailMode>(initialMode);
|
||||
|
||||
// 데이터 로드
|
||||
useEffect(() => {
|
||||
if (pricingId && initialMode !== 'create') {
|
||||
const loadData = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const result = await getPricingDetail(pricingId);
|
||||
if (result.success && result.data) {
|
||||
setPricing(result.data);
|
||||
} else {
|
||||
toast.error(result.error || '단가 정보를 불러오는데 실패했습니다.');
|
||||
router.push('/ko/construction/order/base-info/pricing');
|
||||
}
|
||||
} catch {
|
||||
toast.error('단가 정보를 불러오는데 실패했습니다.');
|
||||
router.push('/ko/construction/order/base-info/pricing');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
loadData();
|
||||
}
|
||||
}, [pricingId, initialMode, router]);
|
||||
|
||||
// 저장 핸들러
|
||||
const handleSubmit = useCallback(
|
||||
async (formData: Record<string, unknown>) => {
|
||||
try {
|
||||
const submitData = pricingDetailConfig.transformSubmitData!(formData) as unknown as PricingFormData;
|
||||
|
||||
if (mode === 'create') {
|
||||
const result = await createPricing(submitData);
|
||||
if (result.success && result.data) {
|
||||
return { success: true };
|
||||
}
|
||||
return { success: false, error: result.error || '단가 등록에 실패했습니다.' };
|
||||
} else if (mode === 'edit' && pricingId) {
|
||||
// edit 모드에서는 수정 가능한 필드만 전송
|
||||
const result = await updatePricing(pricingId, {
|
||||
vendor: submitData.vendor,
|
||||
sellingPrice: submitData.sellingPrice,
|
||||
status: submitData.status,
|
||||
});
|
||||
if (result.success) {
|
||||
return { success: true };
|
||||
}
|
||||
return { success: false, error: result.error || '단가 수정에 실패했습니다.' };
|
||||
}
|
||||
|
||||
return { success: false, error: '알 수 없는 오류가 발생했습니다.' };
|
||||
} catch {
|
||||
return { success: false, error: '저장 중 오류가 발생했습니다.' };
|
||||
}
|
||||
},
|
||||
[mode, pricingId]
|
||||
);
|
||||
|
||||
// 삭제 핸들러
|
||||
const handleDelete = useCallback(async (id: string | number) => {
|
||||
try {
|
||||
const result = await deletePricing(String(id));
|
||||
if (result.success) {
|
||||
return { success: true };
|
||||
}
|
||||
return { success: false, error: result.error || '단가 삭제에 실패했습니다.' };
|
||||
} catch {
|
||||
return { success: false, error: '삭제 중 오류가 발생했습니다.' };
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 모드 변경 핸들러
|
||||
const handleModeChange = useCallback(
|
||||
(newMode: DetailMode) => {
|
||||
if (newMode === 'edit' && pricingId) {
|
||||
// edit 모드로 변경 시 별도 페이지로 이동 (기존 라우트 구조 유지)
|
||||
router.push(`/ko/construction/order/base-info/pricing/${pricingId}/edit`);
|
||||
} else {
|
||||
setMode(newMode);
|
||||
}
|
||||
},
|
||||
[pricingId, router]
|
||||
);
|
||||
|
||||
return (
|
||||
<IntegratedDetailTemplate
|
||||
config={pricingDetailConfig as Parameters<typeof IntegratedDetailTemplate>[0]['config']}
|
||||
mode={mode}
|
||||
initialData={pricing as Record<string, unknown> | undefined}
|
||||
itemId={pricingId}
|
||||
isLoading={isLoading}
|
||||
onSubmit={handleSubmit}
|
||||
onDelete={handleDelete}
|
||||
onModeChange={handleModeChange}
|
||||
buttonPosition="top"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Named export for backwards compatibility
|
||||
export { PricingDetailClientV2 };
|
||||
@@ -1,3 +1,6 @@
|
||||
export { default as PricingListClient } from './PricingListClient';
|
||||
export { default as PricingDetailClient } from './PricingDetailClient';
|
||||
export { default as PricingDetailClientV2 } from './PricingDetailClientV2';
|
||||
export { pricingDetailConfig } from './pricingDetailConfig';
|
||||
export * from './types';
|
||||
export * from './actions';
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* 단가관리 상세 페이지 설정
|
||||
* IntegratedDetailTemplate용 config
|
||||
*/
|
||||
|
||||
import { DollarSign } from 'lucide-react';
|
||||
import type { DetailConfig, FieldDefinition, DetailMode } from '@/components/templates/IntegratedDetailTemplate';
|
||||
import type { PricingFormData, PricingStatus } from './types';
|
||||
import { STATUS_OPTIONS, PRICING_STATUS_LABELS } from './types';
|
||||
import { getVendorList } from './actions';
|
||||
|
||||
// 상태 옵션 (all 제외)
|
||||
const statusFieldOptions = STATUS_OPTIONS
|
||||
.filter((o) => o.value !== 'all')
|
||||
.map((o) => ({ value: o.value, label: o.label }));
|
||||
|
||||
// 필드 정의
|
||||
const fields: FieldDefinition[] = [
|
||||
{
|
||||
key: 'pricingNumber',
|
||||
label: '단가번호',
|
||||
type: 'text',
|
||||
disabled: true, // 항상 비활성화 (자동생성)
|
||||
placeholder: '자동생성',
|
||||
},
|
||||
{
|
||||
key: 'itemType',
|
||||
label: '품목유형',
|
||||
type: 'text',
|
||||
disabled: true, // 항상 비활성화
|
||||
},
|
||||
{
|
||||
key: 'category',
|
||||
label: '카테고리명',
|
||||
type: 'text',
|
||||
disabled: true,
|
||||
},
|
||||
{
|
||||
key: 'itemName',
|
||||
label: '품목명',
|
||||
type: 'text',
|
||||
disabled: true,
|
||||
},
|
||||
{
|
||||
key: 'spec',
|
||||
label: '규격',
|
||||
type: 'text',
|
||||
disabled: true,
|
||||
},
|
||||
{
|
||||
key: 'orderItemValue',
|
||||
label: '무게',
|
||||
type: 'text',
|
||||
disabled: true,
|
||||
helpText: '발주항목 (동적 컬럼)',
|
||||
},
|
||||
{
|
||||
key: 'unit',
|
||||
label: '단위',
|
||||
type: 'text',
|
||||
disabled: true,
|
||||
},
|
||||
{
|
||||
key: 'division',
|
||||
label: '구분',
|
||||
type: 'text',
|
||||
disabled: true,
|
||||
},
|
||||
{
|
||||
key: 'vendor',
|
||||
label: '거래처',
|
||||
type: 'select',
|
||||
disabled: (mode: DetailMode) => mode === 'view',
|
||||
fetchOptions: async () => {
|
||||
const result = await getVendorList();
|
||||
if (result.success && result.data) {
|
||||
return result.data.map((v) => ({ value: v.name, label: v.name }));
|
||||
}
|
||||
return [];
|
||||
},
|
||||
placeholder: '거래처 선택',
|
||||
},
|
||||
{
|
||||
key: 'sellingPrice',
|
||||
label: '판매단가',
|
||||
type: 'number',
|
||||
disabled: (mode: DetailMode) => mode === 'view',
|
||||
placeholder: '판매단가 입력',
|
||||
formatValue: (value) => {
|
||||
if (value === null || value === undefined || value === '') return '-';
|
||||
return Number(value).toLocaleString('ko-KR');
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
label: '상태',
|
||||
type: 'select',
|
||||
disabled: (mode: DetailMode) => mode === 'view',
|
||||
options: statusFieldOptions,
|
||||
placeholder: '상태 선택',
|
||||
formatValue: (value) => {
|
||||
if (!value) return '-';
|
||||
return PRICING_STATUS_LABELS[value as PricingStatus] || String(value);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'note',
|
||||
label: '비고',
|
||||
type: 'textarea',
|
||||
disabled: (mode: DetailMode) => mode === 'view',
|
||||
placeholder: '비고 입력',
|
||||
gridSpan: 2,
|
||||
},
|
||||
];
|
||||
|
||||
// DetailConfig
|
||||
export const pricingDetailConfig: DetailConfig = {
|
||||
title: '단가',
|
||||
description: '단가 정보를 등록하고 관리합니다.',
|
||||
icon: DollarSign,
|
||||
basePath: '/construction/order/base-info/pricing',
|
||||
fields,
|
||||
gridColumns: 2,
|
||||
actions: {
|
||||
showBack: true,
|
||||
showDelete: true,
|
||||
showEdit: true,
|
||||
backLabel: '목록',
|
||||
deleteLabel: '삭제',
|
||||
editLabel: '수정',
|
||||
submitLabel: undefined, // 모드에 따라 자동 결정
|
||||
cancelLabel: '취소',
|
||||
deleteConfirmMessage: {
|
||||
title: '단가 삭제',
|
||||
description: '이 단가를 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.',
|
||||
},
|
||||
},
|
||||
transformInitialData: (data: Record<string, unknown>): Record<string, unknown> => {
|
||||
// orderItems에서 첫 번째 항목 추출
|
||||
const orderItems = data.orderItems as Array<{ name?: string; value?: string }> | undefined;
|
||||
const firstOrderItem = orderItems?.[0];
|
||||
|
||||
return {
|
||||
pricingNumber: data.pricingNumber || '자동생성',
|
||||
itemType: data.itemType,
|
||||
category: data.category,
|
||||
itemName: data.itemName,
|
||||
spec: data.spec,
|
||||
orderItemValue: firstOrderItem?.value || '-',
|
||||
unit: data.unit,
|
||||
division: data.division,
|
||||
vendor: data.vendor,
|
||||
sellingPrice: data.sellingPrice,
|
||||
status: data.status || 'in_use',
|
||||
note: '',
|
||||
};
|
||||
},
|
||||
transformSubmitData: (formData: Record<string, unknown>): Partial<PricingFormData> => ({
|
||||
itemType: formData.itemType as string,
|
||||
category: formData.category as string,
|
||||
itemName: formData.itemName as string,
|
||||
spec: formData.spec as string,
|
||||
orderItems: [], // 현재는 빈 배열 (기존 값 유지)
|
||||
unit: formData.unit as string,
|
||||
division: formData.division as string,
|
||||
vendor: formData.vendor as string,
|
||||
purchasePrice: 0, // 기존 값 유지 필요
|
||||
marginRate: 0, // 기존 값 유지 필요
|
||||
sellingPrice: Number(formData.sellingPrice) || 0,
|
||||
status: formData.status as PricingStatus,
|
||||
}),
|
||||
};
|
||||
|
||||
export default pricingDetailConfig;
|
||||
@@ -0,0 +1,140 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 현장관리 V2 클라이언트 컴포넌트
|
||||
*
|
||||
* V2 라우팅 패턴:
|
||||
* - /site-management/[id] → 조회 모드 (기본)
|
||||
* - /site-management/[id]?mode=edit → 수정 모드
|
||||
*
|
||||
* 기존 /site-management/[id]/edit → /site-management/[id]?mode=edit 으로 리다이렉트
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import SiteDetailForm from './SiteDetailForm';
|
||||
import type { Site } from './types';
|
||||
import { ContentLoadingSpinner } from '@/components/ui/loading-spinner';
|
||||
import { ErrorCard } from '@/components/ui/error-card';
|
||||
|
||||
type DetailMode = 'view' | 'edit';
|
||||
|
||||
interface SiteDetailClientV2Props {
|
||||
siteId: string;
|
||||
initialMode?: DetailMode;
|
||||
}
|
||||
|
||||
// 목업 데이터 (추후 API 연동 시 제거)
|
||||
const MOCK_SITE: Site = {
|
||||
id: '1',
|
||||
siteCode: '123-12-12345',
|
||||
partnerId: '1',
|
||||
partnerName: '거래처명',
|
||||
siteName: '현장명',
|
||||
address: '',
|
||||
status: 'active',
|
||||
createdAt: '2025-09-01T00:00:00Z',
|
||||
updatedAt: '2025-09-01T00:00:00Z',
|
||||
};
|
||||
|
||||
const BASE_PATH = '/ko/construction/order/site-management';
|
||||
|
||||
export function SiteDetailClientV2({ siteId, initialMode }: SiteDetailClientV2Props) {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
// URL의 mode 쿼리 파라미터 확인
|
||||
const modeFromQuery = searchParams.get('mode') as DetailMode | null;
|
||||
|
||||
// 모드 결정: initialMode > query param > 기본값 'view'
|
||||
const [mode, setMode] = useState<DetailMode>(() => {
|
||||
if (initialMode) return initialMode;
|
||||
if (modeFromQuery === 'edit') return 'edit';
|
||||
return 'view';
|
||||
});
|
||||
|
||||
// 데이터 상태
|
||||
const [site, setSite] = useState<Site | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// 데이터 로드
|
||||
useEffect(() => {
|
||||
async function fetchData() {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// TODO: API 연동
|
||||
// const result = await getSiteById(siteId);
|
||||
// if (result.success && result.data) {
|
||||
// setSite(result.data);
|
||||
// } else {
|
||||
// setError(result.error || '현장을 찾을 수 없습니다.');
|
||||
// }
|
||||
|
||||
// 임시: 목업 데이터 사용
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
|
||||
// ID가 숫자가 아니거나 너무 큰 경우 에러
|
||||
const numericId = parseInt(siteId, 10);
|
||||
if (isNaN(numericId) || numericId > 100) {
|
||||
setError('현장을 찾을 수 없습니다.');
|
||||
} else {
|
||||
setSite({ ...MOCK_SITE, id: siteId });
|
||||
}
|
||||
} catch {
|
||||
setError('데이터를 불러오는 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
fetchData();
|
||||
}, [siteId]);
|
||||
|
||||
// URL 쿼리 파라미터 변경 감지
|
||||
useEffect(() => {
|
||||
if (modeFromQuery === 'edit') {
|
||||
setMode('edit');
|
||||
} else if (!modeFromQuery && mode !== 'view') {
|
||||
setMode('view');
|
||||
}
|
||||
}, [modeFromQuery, mode]);
|
||||
|
||||
// ===== 로딩 상태 =====
|
||||
if (isLoading) {
|
||||
return <ContentLoadingSpinner />;
|
||||
}
|
||||
|
||||
// ===== 에러 상태 =====
|
||||
if (error) {
|
||||
return (
|
||||
<ErrorCard
|
||||
type="not-found"
|
||||
title="현장을 찾을 수 없습니다"
|
||||
message={error}
|
||||
actionLabel="목록으로"
|
||||
onAction={() => router.push(BASE_PATH)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// ===== 데이터 없음 =====
|
||||
if (!site) {
|
||||
return (
|
||||
<ErrorCard
|
||||
type="error"
|
||||
title="오류가 발생했습니다"
|
||||
message="잠시 후 다시 시도해주세요."
|
||||
actionLabel="목록으로"
|
||||
onAction={() => router.push(BASE_PATH)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// ===== 정상 렌더링 =====
|
||||
return <SiteDetailForm site={site} mode={mode} />;
|
||||
}
|
||||
|
||||
export default SiteDetailClientV2;
|
||||
@@ -116,7 +116,7 @@ export default function SiteDetailForm({ site, mode = 'view' }: SiteDetailFormPr
|
||||
// 수정 버튼 클릭
|
||||
const handleEditClick = useCallback(() => {
|
||||
if (site?.id) {
|
||||
router.push(`/ko/construction/order/site-management/${site.id}/edit`);
|
||||
router.push(`/ko/construction/order/site-management/${site.id}?mode=edit`);
|
||||
}
|
||||
}, [router, site?.id]);
|
||||
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 구조검토 V2 클라이언트 컴포넌트
|
||||
*
|
||||
* V2 라우팅 패턴:
|
||||
* - /structure-review/[id] → 조회 모드 (기본)
|
||||
* - /structure-review/[id]?mode=edit → 수정 모드
|
||||
*
|
||||
* 기존 /structure-review/[id]/edit → /structure-review/[id]?mode=edit 으로 리다이렉트
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import StructureReviewDetailForm from './StructureReviewDetailForm';
|
||||
import type { StructureReview } from './types';
|
||||
import { ContentLoadingSpinner } from '@/components/ui/loading-spinner';
|
||||
import { ErrorCard } from '@/components/ui/error-card';
|
||||
|
||||
type DetailMode = 'view' | 'edit';
|
||||
|
||||
interface StructureReviewDetailClientV2Props {
|
||||
reviewId: string;
|
||||
initialMode?: DetailMode;
|
||||
}
|
||||
|
||||
// 목업 데이터 (추후 API 연동 시 제거)
|
||||
const MOCK_REVIEW: StructureReview = {
|
||||
id: '1',
|
||||
reviewNumber: '123123',
|
||||
partnerId: '1',
|
||||
partnerName: '거래처명A',
|
||||
siteId: '1',
|
||||
siteName: '현장A',
|
||||
requestDate: '2025-12-12',
|
||||
reviewCompany: '회사명',
|
||||
reviewerName: '홍길동',
|
||||
reviewDate: '2025-12-15',
|
||||
completionDate: null,
|
||||
status: 'pending',
|
||||
createdAt: '2025-12-01T00:00:00Z',
|
||||
updatedAt: '2025-12-01T00:00:00Z',
|
||||
};
|
||||
|
||||
const BASE_PATH = '/ko/construction/order/structure-review';
|
||||
|
||||
export function StructureReviewDetailClientV2({
|
||||
reviewId,
|
||||
initialMode,
|
||||
}: StructureReviewDetailClientV2Props) {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
// URL의 mode 쿼리 파라미터 확인
|
||||
const modeFromQuery = searchParams.get('mode') as DetailMode | null;
|
||||
|
||||
// 모드 결정: initialMode > query param > 기본값 'view'
|
||||
const [mode, setMode] = useState<DetailMode>(() => {
|
||||
if (initialMode) return initialMode;
|
||||
if (modeFromQuery === 'edit') return 'edit';
|
||||
return 'view';
|
||||
});
|
||||
|
||||
// 데이터 상태
|
||||
const [review, setReview] = useState<StructureReview | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// 데이터 로드
|
||||
useEffect(() => {
|
||||
async function fetchData() {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// TODO: API 연동
|
||||
// const result = await getStructureReviewById(reviewId);
|
||||
// if (result.success && result.data) {
|
||||
// setReview(result.data);
|
||||
// } else {
|
||||
// setError(result.error || '구조검토를 찾을 수 없습니다.');
|
||||
// }
|
||||
|
||||
// 임시: 목업 데이터 사용
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
|
||||
// ID가 숫자가 아니거나 너무 큰 경우 에러
|
||||
const numericId = parseInt(reviewId, 10);
|
||||
if (isNaN(numericId) || numericId > 100) {
|
||||
setError('구조검토를 찾을 수 없습니다.');
|
||||
} else {
|
||||
setReview({ ...MOCK_REVIEW, id: reviewId });
|
||||
}
|
||||
} catch {
|
||||
setError('데이터를 불러오는 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
fetchData();
|
||||
}, [reviewId]);
|
||||
|
||||
// URL 쿼리 파라미터 변경 감지
|
||||
useEffect(() => {
|
||||
if (modeFromQuery === 'edit') {
|
||||
setMode('edit');
|
||||
} else if (!modeFromQuery && mode !== 'view') {
|
||||
setMode('view');
|
||||
}
|
||||
}, [modeFromQuery, mode]);
|
||||
|
||||
// ===== 로딩 상태 =====
|
||||
if (isLoading) {
|
||||
return <ContentLoadingSpinner />;
|
||||
}
|
||||
|
||||
// ===== 에러 상태 =====
|
||||
if (error) {
|
||||
return (
|
||||
<ErrorCard
|
||||
type="not-found"
|
||||
title="구조검토를 찾을 수 없습니다"
|
||||
message={error}
|
||||
actionLabel="목록으로"
|
||||
onAction={() => router.push(BASE_PATH)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// ===== 데이터 없음 =====
|
||||
if (!review) {
|
||||
return (
|
||||
<ErrorCard
|
||||
type="error"
|
||||
title="오류가 발생했습니다"
|
||||
message="잠시 후 다시 시도해주세요."
|
||||
actionLabel="목록으로"
|
||||
onAction={() => router.push(BASE_PATH)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// ===== 정상 렌더링 =====
|
||||
return <StructureReviewDetailForm review={review} mode={mode} />;
|
||||
}
|
||||
|
||||
export default StructureReviewDetailClientV2;
|
||||
@@ -125,7 +125,7 @@ export default function StructureReviewDetailForm({
|
||||
// 수정 버튼 클릭
|
||||
const handleEditClick = useCallback(() => {
|
||||
if (review?.id) {
|
||||
router.push(`/ko/construction/order/structure-review/${review.id}/edit`);
|
||||
router.push(`/ko/construction/order/structure-review/${review.id}?mode=edit`);
|
||||
}
|
||||
}, [router, review?.id]);
|
||||
|
||||
|
||||
229
src/components/clients/ClientDetailClientV2.tsx
Normal file
229
src/components/clients/ClientDetailClientV2.tsx
Normal file
@@ -0,0 +1,229 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 거래처(영업) 상세 클라이언트 컴포넌트 V2
|
||||
* IntegratedDetailTemplate 기반 마이그레이션
|
||||
*
|
||||
* 클라이언트 사이드 데이터 페칭 (useClientList 훅 활용)
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate';
|
||||
import type { DetailMode } from '@/components/templates/IntegratedDetailTemplate/types';
|
||||
import type { Client, ClientFormData } from '@/hooks/useClientList';
|
||||
import { useClientList, transformClientToApiCreate, transformClientToApiUpdate } from '@/hooks/useClientList';
|
||||
import { clientDetailConfig } from './clientDetailConfig';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface ClientDetailClientV2Props {
|
||||
clientId?: string;
|
||||
initialMode?: DetailMode;
|
||||
}
|
||||
|
||||
// 8자리 영문+숫자 코드 생성
|
||||
function generateCode(): string {
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
||||
let result = '';
|
||||
for (let i = 0; i < 8; i++) {
|
||||
result += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function ClientDetailClientV2({ clientId, initialMode }: ClientDetailClientV2Props) {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { fetchClient, createClient, updateClient, deleteClient } = useClientList();
|
||||
|
||||
// URL 쿼리에서 모드 결정
|
||||
const modeFromQuery = searchParams.get('mode') as DetailMode | null;
|
||||
const isNewMode = !clientId || clientId === 'new';
|
||||
|
||||
const [mode, setMode] = useState<DetailMode>(() => {
|
||||
if (isNewMode) return 'create';
|
||||
if (initialMode) return initialMode;
|
||||
if (modeFromQuery === 'edit') return 'edit';
|
||||
return 'view';
|
||||
});
|
||||
|
||||
const [clientData, setClientData] = useState<Client | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(!isNewMode);
|
||||
const [generatedCode, setGeneratedCode] = useState<string>('');
|
||||
|
||||
// 데이터 로드
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
if (isNewMode) {
|
||||
// 신규 등록 시 코드 생성
|
||||
const code = generateCode();
|
||||
setGeneratedCode(code);
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const data = await fetchClient(clientId!);
|
||||
if (data) {
|
||||
setClientData(data);
|
||||
} else {
|
||||
toast.error('거래처를 불러오는데 실패했습니다.');
|
||||
router.push(clientDetailConfig.basePath);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('거래처 조회 실패:', error);
|
||||
toast.error('거래처를 불러오는데 실패했습니다.');
|
||||
router.push(clientDetailConfig.basePath);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadData();
|
||||
}, [clientId, isNewMode, router, fetchClient]);
|
||||
|
||||
// URL 쿼리 변경 감지
|
||||
useEffect(() => {
|
||||
if (!isNewMode && modeFromQuery === 'edit') {
|
||||
setMode('edit');
|
||||
} else if (!isNewMode && !modeFromQuery) {
|
||||
setMode('view');
|
||||
}
|
||||
}, [modeFromQuery, isNewMode]);
|
||||
|
||||
// 모드 변경 핸들러
|
||||
const handleModeChange = useCallback(
|
||||
(newMode: DetailMode) => {
|
||||
setMode(newMode);
|
||||
if (newMode === 'edit' && clientId) {
|
||||
router.push(`${clientDetailConfig.basePath}/${clientId}?mode=edit`);
|
||||
} else if (newMode === 'view' && clientId) {
|
||||
router.push(`${clientDetailConfig.basePath}/${clientId}`);
|
||||
}
|
||||
},
|
||||
[router, clientId]
|
||||
);
|
||||
|
||||
// 저장 핸들러
|
||||
const handleSubmit = useCallback(
|
||||
async (formData: Record<string, unknown>) => {
|
||||
try {
|
||||
// formData를 ClientFormData로 변환
|
||||
const clientFormData: ClientFormData = {
|
||||
clientCode: (formData.clientCode as string) || generatedCode,
|
||||
name: formData.name as string,
|
||||
businessNo: formData.businessNo as string,
|
||||
representative: formData.representative as string,
|
||||
phone: formData.phone as string,
|
||||
address: formData.address as string,
|
||||
email: formData.email as string,
|
||||
businessType: formData.businessType as string,
|
||||
businessItem: formData.businessItem as string,
|
||||
isActive: formData.isActive === 'true',
|
||||
clientType: (formData.clientType as ClientFormData['clientType']) || '매입',
|
||||
mobile: formData.mobile as string,
|
||||
fax: formData.fax as string,
|
||||
managerName: formData.managerName as string,
|
||||
managerTel: formData.managerTel as string,
|
||||
systemManager: formData.systemManager as string,
|
||||
accountId: formData.accountId as string || '',
|
||||
accountPassword: formData.accountPassword as string || '',
|
||||
purchasePaymentDay: '말일',
|
||||
salesPaymentDay: '말일',
|
||||
taxAgreement: false,
|
||||
taxAmount: '',
|
||||
taxStartDate: '',
|
||||
taxEndDate: '',
|
||||
badDebt: false,
|
||||
badDebtAmount: '',
|
||||
badDebtReceiveDate: '',
|
||||
badDebtEndDate: '',
|
||||
badDebtProgress: '',
|
||||
memo: formData.memo as string || '',
|
||||
};
|
||||
|
||||
if (isNewMode) {
|
||||
const result = await createClient(clientFormData);
|
||||
if (result) {
|
||||
toast.success('거래처가 등록되었습니다.');
|
||||
router.push(clientDetailConfig.basePath);
|
||||
return { success: true };
|
||||
}
|
||||
return { success: false, error: '거래처 등록에 실패했습니다.' };
|
||||
} else {
|
||||
const result = await updateClient(clientId!, clientFormData);
|
||||
if (result) {
|
||||
toast.success('거래처가 수정되었습니다.');
|
||||
router.push(`${clientDetailConfig.basePath}/${clientId}`);
|
||||
return { success: true };
|
||||
}
|
||||
return { success: false, error: '거래처 수정에 실패했습니다.' };
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('저장 실패:', error);
|
||||
return { success: false, error: error instanceof Error ? error.message : '저장 중 오류가 발생했습니다.' };
|
||||
}
|
||||
},
|
||||
[isNewMode, clientId, generatedCode, router, createClient, updateClient]
|
||||
);
|
||||
|
||||
// 삭제 핸들러
|
||||
const handleDelete = useCallback(
|
||||
async (id: string | number) => {
|
||||
try {
|
||||
const result = await deleteClient(String(id));
|
||||
if (result) {
|
||||
toast.success('거래처가 삭제되었습니다.');
|
||||
router.push(clientDetailConfig.basePath);
|
||||
return { success: true };
|
||||
}
|
||||
return { success: false, error: '거래처 삭제에 실패했습니다.' };
|
||||
} catch (error) {
|
||||
console.error('삭제 실패:', error);
|
||||
return { success: false, error: error instanceof Error ? error.message : '삭제 중 오류가 발생했습니다.' };
|
||||
}
|
||||
},
|
||||
[router, deleteClient]
|
||||
);
|
||||
|
||||
// 취소 핸들러
|
||||
const handleCancel = useCallback(() => {
|
||||
if (isNewMode) {
|
||||
router.push(clientDetailConfig.basePath);
|
||||
} else {
|
||||
setMode('view');
|
||||
router.push(`${clientDetailConfig.basePath}/${clientId}`);
|
||||
}
|
||||
}, [router, clientId, isNewMode]);
|
||||
|
||||
// 초기 데이터 (신규 등록 시 코드 포함)
|
||||
const initialData = isNewMode
|
||||
? ({ code: generatedCode } as Client)
|
||||
: clientData || undefined;
|
||||
|
||||
// 타이틀 동적 설정
|
||||
const dynamicConfig = {
|
||||
...clientDetailConfig,
|
||||
title:
|
||||
mode === 'create'
|
||||
? '거래처'
|
||||
: mode === 'edit'
|
||||
? clientData?.name || '거래처'
|
||||
: clientData?.name || '거래처 상세',
|
||||
};
|
||||
|
||||
return (
|
||||
<IntegratedDetailTemplate
|
||||
config={dynamicConfig}
|
||||
mode={mode}
|
||||
initialData={initialData}
|
||||
itemId={clientId}
|
||||
isLoading={isLoading}
|
||||
onSubmit={handleSubmit}
|
||||
onDelete={handleDelete}
|
||||
onCancel={handleCancel}
|
||||
onModeChange={handleModeChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
257
src/components/clients/actions.ts
Normal file
257
src/components/clients/actions.ts
Normal file
@@ -0,0 +1,257 @@
|
||||
'use server';
|
||||
|
||||
/**
|
||||
* 거래처(영업) 서버 액션
|
||||
*
|
||||
* API Endpoints:
|
||||
* - GET /api/v1/clients/{id} - 상세 조회
|
||||
* - POST /api/v1/clients - 등록
|
||||
* - PUT /api/v1/clients/{id} - 수정
|
||||
* - DELETE /api/v1/clients/{id} - 삭제
|
||||
*
|
||||
* 🚨 401 에러 시 __authError: true 반환 → 클라이언트에서 로그인 페이지로 리다이렉트
|
||||
*/
|
||||
|
||||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||
import { serverFetch } from '@/lib/api/fetch-wrapper';
|
||||
import type { Client, ClientFormData, ClientApiResponse } from '@/hooks/useClientList';
|
||||
import {
|
||||
transformClientFromApi,
|
||||
transformClientToApiCreate,
|
||||
transformClientToApiUpdate,
|
||||
} from '@/hooks/useClientList';
|
||||
|
||||
// ===== 응답 타입 =====
|
||||
interface ActionResponse<T = unknown> {
|
||||
success: boolean;
|
||||
data?: T;
|
||||
error?: string;
|
||||
__authError?: boolean;
|
||||
}
|
||||
|
||||
interface ApiResponse<T> {
|
||||
success: boolean;
|
||||
data: T;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
// ===== 거래처 단건 조회 =====
|
||||
export async function getClientById(id: string): Promise<ActionResponse<Client>> {
|
||||
try {
|
||||
const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/clients/${id}`;
|
||||
console.log('[ClientActions] GET client URL:', url);
|
||||
|
||||
const { response, error } = await serverFetch(url, { method: 'GET' });
|
||||
|
||||
// 🚨 401 인증 에러
|
||||
if (error?.__authError) {
|
||||
console.error('[ClientActions] Auth error:', error);
|
||||
return { success: false, __authError: true };
|
||||
}
|
||||
|
||||
if (!response) {
|
||||
console.error('[ClientActions] No response, error:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error?.message || '서버 응답이 없습니다.',
|
||||
};
|
||||
}
|
||||
|
||||
// 응답 텍스트 먼저 읽기
|
||||
const responseText = await response.text();
|
||||
console.log('[ClientActions] Response status:', response.status);
|
||||
console.log('[ClientActions] Response text:', responseText);
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('[ClientActions] GET client error:', response.status);
|
||||
return {
|
||||
success: false,
|
||||
error: `API 오류: ${response.status}`,
|
||||
};
|
||||
}
|
||||
|
||||
// JSON 파싱
|
||||
let result: ApiResponse<ClientApiResponse>;
|
||||
try {
|
||||
result = JSON.parse(responseText);
|
||||
} catch {
|
||||
console.error('[ClientActions] JSON parse error');
|
||||
return {
|
||||
success: false,
|
||||
error: 'JSON 파싱 오류',
|
||||
};
|
||||
}
|
||||
|
||||
if (!result.success || !result.data) {
|
||||
console.error('[ClientActions] API returned error:', result);
|
||||
return {
|
||||
success: false,
|
||||
error: result.message || '거래처를 찾을 수 없습니다.',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: transformClientFromApi(result.data),
|
||||
};
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('[ClientActions] getClientById error:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : '거래처 조회 중 오류가 발생했습니다.',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 거래처 생성 =====
|
||||
export async function createClient(
|
||||
formData: Partial<ClientFormData>
|
||||
): Promise<ActionResponse<Client>> {
|
||||
try {
|
||||
const apiData = transformClientToApiCreate(formData as ClientFormData);
|
||||
const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/clients`;
|
||||
|
||||
console.log('[ClientActions] POST client request:', apiData);
|
||||
|
||||
const { response, error } = await serverFetch(url, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(apiData),
|
||||
});
|
||||
|
||||
// 🚨 401 인증 에러
|
||||
if (error?.__authError) {
|
||||
return { success: false, __authError: true };
|
||||
}
|
||||
|
||||
if (!response) {
|
||||
return {
|
||||
success: false,
|
||||
error: error?.message || '서버 오류가 발생했습니다.',
|
||||
};
|
||||
}
|
||||
|
||||
const result: ApiResponse<ClientApiResponse> = await response.json();
|
||||
console.log('[ClientActions] POST client response:', result);
|
||||
|
||||
if (!response.ok || !result.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: result.message || '거래처 생성에 실패했습니다.',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: transformClientFromApi(result.data),
|
||||
};
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('[ClientActions] createClient error:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : '거래처 생성 중 오류가 발생했습니다.',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 거래처 수정 =====
|
||||
export async function updateClient(
|
||||
id: string,
|
||||
formData: Partial<ClientFormData>
|
||||
): Promise<ActionResponse<Client>> {
|
||||
try {
|
||||
const apiData = transformClientToApiUpdate(formData as ClientFormData);
|
||||
const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/clients/${id}`;
|
||||
|
||||
console.log('[ClientActions] PUT client request:', apiData);
|
||||
|
||||
const { response, error } = await serverFetch(url, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(apiData),
|
||||
});
|
||||
|
||||
// 🚨 401 인증 에러
|
||||
if (error?.__authError) {
|
||||
return { success: false, __authError: true };
|
||||
}
|
||||
|
||||
if (!response) {
|
||||
return {
|
||||
success: false,
|
||||
error: error?.message || '서버 오류가 발생했습니다.',
|
||||
};
|
||||
}
|
||||
|
||||
const result: ApiResponse<ClientApiResponse> = await response.json();
|
||||
console.log('[ClientActions] PUT client response:', result);
|
||||
|
||||
if (!response.ok || !result.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: result.message || '거래처 수정에 실패했습니다.',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: transformClientFromApi(result.data),
|
||||
};
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('[ClientActions] updateClient error:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : '거래처 수정 중 오류가 발생했습니다.',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 거래처 삭제 =====
|
||||
export async function deleteClient(id: string): Promise<ActionResponse> {
|
||||
try {
|
||||
const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/clients/${id}`;
|
||||
const { response, error } = await serverFetch(url, { method: 'DELETE' });
|
||||
|
||||
// 🚨 401 인증 에러
|
||||
if (error?.__authError) {
|
||||
return { success: false, __authError: true };
|
||||
}
|
||||
|
||||
if (!response) {
|
||||
return {
|
||||
success: false,
|
||||
error: error?.message || '서버 오류가 발생했습니다.',
|
||||
};
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
console.log('[ClientActions] DELETE client response:', result);
|
||||
|
||||
if (!response.ok || !result.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: result.message || '거래처 삭제에 실패했습니다.',
|
||||
};
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('[ClientActions] deleteClient error:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : '거래처 삭제 중 오류가 발생했습니다.',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 거래처 코드 생성 (8자리 영문+숫자) =====
|
||||
export async function generateClientCode(): Promise<string> {
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
||||
let result = '';
|
||||
for (let i = 0; i < 8; i++) {
|
||||
result += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
276
src/components/clients/clientDetailConfig.ts
Normal file
276
src/components/clients/clientDetailConfig.ts
Normal file
@@ -0,0 +1,276 @@
|
||||
/**
|
||||
* 거래처(영업) 상세 페이지 설정
|
||||
* IntegratedDetailTemplate V2 마이그레이션
|
||||
*/
|
||||
|
||||
import { Building2 } from 'lucide-react';
|
||||
import type { DetailConfig, FieldDefinition, SectionDefinition } from '@/components/templates/IntegratedDetailTemplate/types';
|
||||
import type { ClientFormData, Client } from '@/hooks/useClientList';
|
||||
|
||||
// ===== 거래처 유형 옵션 =====
|
||||
const CLIENT_TYPE_OPTIONS = [
|
||||
{ value: '매입', label: '매입' },
|
||||
{ value: '매출', label: '매출' },
|
||||
{ value: '매입매출', label: '매입매출' },
|
||||
];
|
||||
|
||||
// ===== 상태 옵션 =====
|
||||
const STATUS_OPTIONS = [
|
||||
{ value: 'true', label: '활성' },
|
||||
{ value: 'false', label: '비활성' },
|
||||
];
|
||||
|
||||
// ===== 필드 정의 =====
|
||||
export const clientFields: FieldDefinition[] = [
|
||||
// 기본 정보
|
||||
{
|
||||
key: 'businessNo',
|
||||
label: '사업자등록번호',
|
||||
type: 'text',
|
||||
required: true,
|
||||
placeholder: '10자리 숫자 (예: 123-45-67890)',
|
||||
validation: [
|
||||
{ type: 'required', message: '사업자등록번호를 입력해주세요.' },
|
||||
{
|
||||
type: 'custom',
|
||||
message: '사업자등록번호는 10자리 숫자여야 합니다.',
|
||||
validate: (value) => {
|
||||
const digits = String(value || '').replace(/-/g, '').trim();
|
||||
return /^\d{10}$/.test(digits);
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'clientCode',
|
||||
label: '거래처 코드',
|
||||
type: 'text',
|
||||
disabled: true,
|
||||
helpText: '자동 생성됩니다',
|
||||
},
|
||||
{
|
||||
key: 'name',
|
||||
label: '거래처명',
|
||||
type: 'text',
|
||||
required: true,
|
||||
placeholder: '거래처명 입력',
|
||||
validation: [
|
||||
{ type: 'required', message: '거래처명을 입력해주세요.' },
|
||||
{ type: 'minLength', value: 2, message: '거래처명은 2자 이상 입력해주세요.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'representative',
|
||||
label: '대표자명',
|
||||
type: 'text',
|
||||
required: true,
|
||||
placeholder: '대표자명 입력',
|
||||
validation: [
|
||||
{ type: 'required', message: '대표자명을 입력해주세요.' },
|
||||
{ type: 'minLength', value: 2, message: '대표자명은 2자 이상 입력해주세요.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'clientType',
|
||||
label: '거래처 유형',
|
||||
type: 'radio',
|
||||
required: true,
|
||||
options: CLIENT_TYPE_OPTIONS,
|
||||
},
|
||||
{
|
||||
key: 'businessType',
|
||||
label: '업태',
|
||||
type: 'text',
|
||||
placeholder: '제조업, 도소매업 등',
|
||||
},
|
||||
{
|
||||
key: 'businessItem',
|
||||
label: '종목',
|
||||
type: 'text',
|
||||
placeholder: '철강, 건설 등',
|
||||
},
|
||||
|
||||
// 연락처 정보
|
||||
{
|
||||
key: 'address',
|
||||
label: '주소',
|
||||
type: 'text',
|
||||
placeholder: '주소 입력',
|
||||
gridSpan: 2,
|
||||
},
|
||||
{
|
||||
key: 'phone',
|
||||
label: '전화번호',
|
||||
type: 'tel',
|
||||
placeholder: '02-1234-5678',
|
||||
},
|
||||
{
|
||||
key: 'mobile',
|
||||
label: '모바일',
|
||||
type: 'tel',
|
||||
placeholder: '010-1234-5678',
|
||||
},
|
||||
{
|
||||
key: 'fax',
|
||||
label: '팩스',
|
||||
type: 'tel',
|
||||
placeholder: '02-1234-5678',
|
||||
},
|
||||
{
|
||||
key: 'email',
|
||||
label: '이메일',
|
||||
type: 'email',
|
||||
placeholder: 'example@company.com',
|
||||
validation: [
|
||||
{
|
||||
type: 'custom',
|
||||
message: '올바른 이메일 형식이 아닙니다.',
|
||||
validate: (value) => {
|
||||
if (!value) return true; // 선택 필드
|
||||
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(String(value));
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// 담당자 정보
|
||||
{
|
||||
key: 'managerName',
|
||||
label: '담당자명',
|
||||
type: 'text',
|
||||
placeholder: '담당자명 입력',
|
||||
},
|
||||
{
|
||||
key: 'managerTel',
|
||||
label: '담당자 전화',
|
||||
type: 'tel',
|
||||
placeholder: '010-1234-5678',
|
||||
},
|
||||
{
|
||||
key: 'systemManager',
|
||||
label: '시스템 관리자',
|
||||
type: 'text',
|
||||
placeholder: '시스템 관리자명',
|
||||
},
|
||||
|
||||
// 기타 정보
|
||||
{
|
||||
key: 'memo',
|
||||
label: '메모',
|
||||
type: 'textarea',
|
||||
placeholder: '메모 입력',
|
||||
gridSpan: 2,
|
||||
},
|
||||
{
|
||||
key: 'isActive',
|
||||
label: '상태',
|
||||
type: 'radio',
|
||||
options: STATUS_OPTIONS,
|
||||
defaultValue: 'true',
|
||||
},
|
||||
];
|
||||
|
||||
// ===== 섹션 정의 =====
|
||||
export const clientSections: SectionDefinition[] = [
|
||||
{
|
||||
id: 'basicInfo',
|
||||
title: '기본 정보',
|
||||
description: '거래처의 기본 정보를 입력하세요',
|
||||
fields: ['businessNo', 'clientCode', 'name', 'representative', 'clientType', 'businessType', 'businessItem'],
|
||||
},
|
||||
{
|
||||
id: 'contactInfo',
|
||||
title: '연락처 정보',
|
||||
description: '거래처의 연락처 정보를 입력하세요',
|
||||
fields: ['address', 'phone', 'mobile', 'fax', 'email'],
|
||||
},
|
||||
{
|
||||
id: 'managerInfo',
|
||||
title: '담당자 정보',
|
||||
description: '거래처 담당자 정보를 입력하세요',
|
||||
fields: ['managerName', 'managerTel', 'systemManager'],
|
||||
},
|
||||
{
|
||||
id: 'otherInfo',
|
||||
title: '기타 정보',
|
||||
description: '추가 정보를 입력하세요',
|
||||
fields: ['memo', 'isActive'],
|
||||
},
|
||||
];
|
||||
|
||||
// ===== 설정 =====
|
||||
export const clientDetailConfig: DetailConfig<Client> = {
|
||||
title: '거래처',
|
||||
description: '거래처 정보를 관리합니다',
|
||||
icon: Building2,
|
||||
basePath: '/ko/sales/client-management-sales-admin',
|
||||
fields: clientFields,
|
||||
sections: clientSections,
|
||||
gridColumns: 2,
|
||||
actions: {
|
||||
submitLabel: '저장',
|
||||
cancelLabel: '취소',
|
||||
showDelete: true,
|
||||
deleteLabel: '삭제',
|
||||
showEdit: true,
|
||||
editLabel: '수정',
|
||||
showBack: true,
|
||||
backLabel: '목록',
|
||||
deleteConfirmMessage: {
|
||||
title: '거래처 삭제',
|
||||
description: '이 거래처를 삭제하시겠습니까? 삭제된 데이터는 복구할 수 없습니다.',
|
||||
},
|
||||
},
|
||||
transformInitialData: (data: Client) => ({
|
||||
businessNo: data.businessNo || '',
|
||||
clientCode: data.code || '',
|
||||
name: data.name || '',
|
||||
representative: data.representative || '',
|
||||
clientType: data.clientType || '매입',
|
||||
businessType: data.businessType || '',
|
||||
businessItem: data.businessItem || '',
|
||||
address: data.address || '',
|
||||
phone: data.phone || '',
|
||||
mobile: data.mobile || '',
|
||||
fax: data.fax || '',
|
||||
email: data.email || '',
|
||||
managerName: data.managerName || '',
|
||||
managerTel: data.managerTel || '',
|
||||
systemManager: data.systemManager || '',
|
||||
memo: data.memo || '',
|
||||
isActive: data.status === '활성' ? 'true' : 'false',
|
||||
}),
|
||||
transformSubmitData: (formData): Partial<ClientFormData> => ({
|
||||
clientCode: formData.clientCode as string,
|
||||
name: formData.name as string,
|
||||
businessNo: formData.businessNo as string,
|
||||
representative: formData.representative as string,
|
||||
clientType: formData.clientType as ClientFormData['clientType'],
|
||||
businessType: formData.businessType as string,
|
||||
businessItem: formData.businessItem as string,
|
||||
address: formData.address as string,
|
||||
phone: formData.phone as string,
|
||||
mobile: formData.mobile as string,
|
||||
fax: formData.fax as string,
|
||||
email: formData.email as string,
|
||||
managerName: formData.managerName as string,
|
||||
managerTel: formData.managerTel as string,
|
||||
systemManager: formData.systemManager as string,
|
||||
memo: formData.memo as string,
|
||||
isActive: formData.isActive === 'true',
|
||||
// 기본값 설정
|
||||
purchasePaymentDay: '말일',
|
||||
salesPaymentDay: '말일',
|
||||
taxAgreement: false,
|
||||
taxAmount: '',
|
||||
taxStartDate: '',
|
||||
taxEndDate: '',
|
||||
badDebt: false,
|
||||
badDebtAmount: '',
|
||||
badDebtReceiveDate: '',
|
||||
badDebtEndDate: '',
|
||||
badDebtProgress: '',
|
||||
accountId: '',
|
||||
accountPassword: '',
|
||||
}),
|
||||
};
|
||||
@@ -74,7 +74,7 @@ export function InquiryDetail({
|
||||
}, [router]);
|
||||
|
||||
const handleEdit = useCallback(() => {
|
||||
router.push(`/ko/customer-center/qna/${inquiry.id}/edit`);
|
||||
router.push(`/ko/customer-center/qna/${inquiry.id}?mode=edit`);
|
||||
}, [router, inquiry.id]);
|
||||
|
||||
const handleConfirmDelete = useCallback(async () => {
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 1:1 문의 V2 클라이언트 컴포넌트
|
||||
*
|
||||
* V2 라우팅 패턴:
|
||||
* - /qna/[id] → 조회 모드 (기본)
|
||||
* - /qna/[id]?mode=edit → 수정 모드
|
||||
* - /qna/create → 등록 모드
|
||||
*
|
||||
* 기존 /qna/[id]/edit → /qna/[id]?mode=edit 으로 리다이렉트
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { InquiryDetail } from './InquiryDetail';
|
||||
import { InquiryForm } from './InquiryForm';
|
||||
import { transformPostToInquiry, type Inquiry, type Comment } from './types';
|
||||
import {
|
||||
getPost,
|
||||
getComments,
|
||||
createComment,
|
||||
updateComment,
|
||||
deleteComment,
|
||||
deletePost,
|
||||
} from '../shared/actions';
|
||||
import { transformApiToComment } from '../shared/types';
|
||||
import { ContentLoadingSpinner } from '@/components/ui/loading-spinner';
|
||||
import { ErrorCard } from '@/components/ui/error-card';
|
||||
|
||||
type DetailMode = 'view' | 'edit' | 'create';
|
||||
|
||||
interface InquiryDetailClientV2Props {
|
||||
inquiryId?: string;
|
||||
initialMode?: DetailMode;
|
||||
}
|
||||
|
||||
const BASE_PATH = '/ko/customer-center/qna';
|
||||
|
||||
export function InquiryDetailClientV2({ inquiryId, initialMode }: InquiryDetailClientV2Props) {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
// URL의 mode 쿼리 파라미터 확인
|
||||
const modeFromQuery = searchParams.get('mode') as DetailMode | null;
|
||||
const isCreateMode = !inquiryId || inquiryId === 'create';
|
||||
|
||||
// 모드 결정: create > initialMode > query param > 기본값 'view'
|
||||
const [mode, setMode] = useState<DetailMode>(() => {
|
||||
if (isCreateMode) return 'create';
|
||||
if (initialMode) return initialMode;
|
||||
if (modeFromQuery === 'edit') return 'edit';
|
||||
return 'view';
|
||||
});
|
||||
|
||||
// 데이터 상태
|
||||
const [inquiry, setInquiry] = useState<Inquiry | null>(null);
|
||||
const [comments, setComments] = useState<Comment[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(!isCreateMode);
|
||||
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);
|
||||
setCurrentUserId(String(user.id || ''));
|
||||
} catch {
|
||||
setCurrentUserId('');
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 데이터 로드
|
||||
useEffect(() => {
|
||||
if (isCreateMode) return;
|
||||
|
||||
async function fetchData() {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
// 게시글과 댓글 동시 로드
|
||||
const [postResult, commentsResult] = await Promise.all([
|
||||
getPost('qna', inquiryId!),
|
||||
getComments('qna', inquiryId!),
|
||||
]);
|
||||
|
||||
if (postResult.success && postResult.data) {
|
||||
setInquiry(transformPostToInquiry(postResult.data));
|
||||
} else {
|
||||
setError(postResult.error || '문의를 찾을 수 없습니다.');
|
||||
}
|
||||
|
||||
if (commentsResult.success && commentsResult.data) {
|
||||
setComments(commentsResult.data.map(transformApiToComment));
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
||||
fetchData();
|
||||
}, [inquiryId, isCreateMode]);
|
||||
|
||||
// URL 쿼리 파라미터 변경 감지
|
||||
useEffect(() => {
|
||||
if (isCreateMode) return;
|
||||
|
||||
if (modeFromQuery === 'edit') {
|
||||
setMode('edit');
|
||||
} else if (!modeFromQuery && mode !== 'view') {
|
||||
setMode('view');
|
||||
}
|
||||
}, [modeFromQuery, isCreateMode, mode]);
|
||||
|
||||
// ===== 댓글 핸들러 =====
|
||||
const handleAddComment = useCallback(
|
||||
async (content: string) => {
|
||||
if (!inquiryId) return;
|
||||
const result = await createComment('qna', inquiryId, content);
|
||||
if (result.success && result.data) {
|
||||
setComments((prev) => [...prev, transformApiToComment(result.data!)]);
|
||||
} else {
|
||||
console.error('댓글 등록 실패:', result.error);
|
||||
}
|
||||
},
|
||||
[inquiryId]
|
||||
);
|
||||
|
||||
const handleUpdateComment = useCallback(
|
||||
async (commentId: string, content: string) => {
|
||||
if (!inquiryId) return;
|
||||
const result = await updateComment('qna', inquiryId, commentId, content);
|
||||
if (result.success && result.data) {
|
||||
setComments((prev) =>
|
||||
prev.map((c) => (c.id === commentId ? transformApiToComment(result.data!) : c))
|
||||
);
|
||||
} else {
|
||||
console.error('댓글 수정 실패:', result.error);
|
||||
}
|
||||
},
|
||||
[inquiryId]
|
||||
);
|
||||
|
||||
const handleDeleteComment = useCallback(
|
||||
async (commentId: string) => {
|
||||
if (!inquiryId) return;
|
||||
const result = await deleteComment('qna', inquiryId, commentId);
|
||||
if (result.success) {
|
||||
setComments((prev) => prev.filter((c) => c.id !== commentId));
|
||||
} else {
|
||||
console.error('댓글 삭제 실패:', result.error);
|
||||
}
|
||||
},
|
||||
[inquiryId]
|
||||
);
|
||||
|
||||
// 문의 삭제
|
||||
const handleDeleteInquiry = useCallback(async () => {
|
||||
if (!inquiryId) return false;
|
||||
const result = await deletePost('qna', inquiryId);
|
||||
return result.success;
|
||||
}, [inquiryId]);
|
||||
|
||||
// ===== 로딩 상태 =====
|
||||
if (isLoading) {
|
||||
return <ContentLoadingSpinner />;
|
||||
}
|
||||
|
||||
// ===== 에러 상태 =====
|
||||
if (error) {
|
||||
return (
|
||||
<ErrorCard
|
||||
type="not-found"
|
||||
title="문의를 찾을 수 없습니다"
|
||||
message={error}
|
||||
actionLabel="목록으로"
|
||||
onAction={() => router.push(BASE_PATH)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// ===== 등록 모드 =====
|
||||
if (mode === 'create') {
|
||||
return <InquiryForm mode="create" />;
|
||||
}
|
||||
|
||||
// ===== 수정 모드 =====
|
||||
if (mode === 'edit' && inquiry) {
|
||||
return <InquiryForm mode="edit" initialData={inquiry} />;
|
||||
}
|
||||
|
||||
// ===== 조회 모드 =====
|
||||
if (inquiry) {
|
||||
return (
|
||||
<InquiryDetail
|
||||
inquiry={inquiry}
|
||||
comments={comments}
|
||||
currentUserId={currentUserId}
|
||||
onAddComment={handleAddComment}
|
||||
onUpdateComment={handleUpdateComment}
|
||||
onDeleteComment={handleDeleteComment}
|
||||
onDeleteInquiry={handleDeleteInquiry}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// 데이터 없음 (비정상 상태)
|
||||
return (
|
||||
<ErrorCard
|
||||
type="error"
|
||||
title="오류가 발생했습니다"
|
||||
message="잠시 후 다시 시도해주세요."
|
||||
actionLabel="목록으로"
|
||||
onAction={() => router.push(BASE_PATH)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default InquiryDetailClientV2;
|
||||
@@ -1,4 +1,5 @@
|
||||
export { InquiryList } from './InquiryList';
|
||||
export { InquiryDetail } from './InquiryDetail';
|
||||
export { InquiryForm } from './InquiryForm';
|
||||
export { InquiryDetailClientV2 } from './InquiryDetailClientV2';
|
||||
export * from './types';
|
||||
@@ -127,7 +127,7 @@ export function AttendanceInfoDialog({
|
||||
onValueChange={(value) => handleChange('employeeId', value)}
|
||||
>
|
||||
<SelectTrigger className="w-[200px]">
|
||||
<SelectValue placeholder="선택 ▼" />
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{employees.map((employee) => (
|
||||
|
||||
@@ -92,7 +92,7 @@ export function ReasonInfoDialog({
|
||||
onValueChange={(value) => handleChange('employeeId', value)}
|
||||
>
|
||||
<SelectTrigger className="w-[200px]">
|
||||
<SelectValue placeholder="선택 ▼" />
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{employees.map((employee) => (
|
||||
@@ -140,7 +140,7 @@ export function ReasonInfoDialog({
|
||||
onValueChange={(value) => handleChange('reasonType', value)}
|
||||
>
|
||||
<SelectTrigger className="w-[200px]">
|
||||
<SelectValue placeholder="선택 ▼" />
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(REASON_TYPE_LABELS).map(([value, label]) => (
|
||||
|
||||
136
src/components/process-management/ProcessDetailClientV2.tsx
Normal file
136
src/components/process-management/ProcessDetailClientV2.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 공정관리 상세 클라이언트 컴포넌트 V2
|
||||
*
|
||||
* 라우팅 구조 변경: /[id], /[id]/edit, /new → /[id]?mode=view|edit, /new
|
||||
* 기존 ProcessDetail, ProcessForm 컴포넌트 활용
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { ProcessDetail } from './ProcessDetail';
|
||||
import { ProcessForm } from './ProcessForm';
|
||||
import { getProcessById } from './actions';
|
||||
import type { Process } from '@/types/process';
|
||||
import { ContentLoadingSpinner } from '@/components/ui/loading-spinner';
|
||||
import { ErrorCard } from '@/components/ui/error-card';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
type DetailMode = 'view' | 'edit' | 'create';
|
||||
|
||||
interface ProcessDetailClientV2Props {
|
||||
processId?: string;
|
||||
initialMode?: DetailMode;
|
||||
}
|
||||
|
||||
const BASE_PATH = '/ko/master-data/process-management';
|
||||
|
||||
export function ProcessDetailClientV2({ processId, initialMode }: ProcessDetailClientV2Props) {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
// URL 쿼리에서 모드 결정
|
||||
const modeFromQuery = searchParams.get('mode') as DetailMode | null;
|
||||
const isNewMode = !processId || processId === 'new';
|
||||
|
||||
const [mode, setMode] = useState<DetailMode>(() => {
|
||||
if (isNewMode) return 'create';
|
||||
if (initialMode) return initialMode;
|
||||
if (modeFromQuery === 'edit') return 'edit';
|
||||
return 'view';
|
||||
});
|
||||
|
||||
const [processData, setProcessData] = useState<Process | 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 getProcessById(processId!);
|
||||
if (result.success && result.data) {
|
||||
setProcessData(result.data);
|
||||
} else {
|
||||
setError(result.error || '공정 정보를 찾을 수 없습니다.');
|
||||
toast.error('공정을 불러오는데 실패했습니다.');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('공정 조회 실패:', err);
|
||||
setError('공정 정보를 불러오는 중 오류가 발생했습니다.');
|
||||
toast.error('공정을 불러오는데 실패했습니다.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadData();
|
||||
}, [processId, isNewMode]);
|
||||
|
||||
// URL 쿼리 변경 감지
|
||||
useEffect(() => {
|
||||
if (!isNewMode && modeFromQuery === 'edit') {
|
||||
setMode('edit');
|
||||
} else if (!isNewMode && !modeFromQuery) {
|
||||
setMode('view');
|
||||
}
|
||||
}, [modeFromQuery, isNewMode]);
|
||||
|
||||
// 로딩 중
|
||||
if (isLoading) {
|
||||
return <ContentLoadingSpinner text="공정 정보를 불러오는 중..." />;
|
||||
}
|
||||
|
||||
// 에러 발생 (view/edit 모드에서)
|
||||
if (error && !isNewMode) {
|
||||
return (
|
||||
<ErrorCard
|
||||
type="network"
|
||||
title="공정 정보를 불러올 수 없습니다"
|
||||
description={error}
|
||||
tips={[
|
||||
'해당 공정이 존재하는지 확인해주세요',
|
||||
'인터넷 연결 상태를 확인해주세요',
|
||||
'잠시 후 다시 시도해주세요',
|
||||
]}
|
||||
homeButtonLabel="목록으로 이동"
|
||||
homeButtonHref={BASE_PATH}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// 등록 모드
|
||||
if (mode === 'create') {
|
||||
return <ProcessForm mode="create" />;
|
||||
}
|
||||
|
||||
// 수정 모드
|
||||
if (mode === 'edit' && processData) {
|
||||
return <ProcessForm mode="edit" initialData={processData} />;
|
||||
}
|
||||
|
||||
// 상세 보기 모드
|
||||
if (mode === 'view' && processData) {
|
||||
return <ProcessDetail process={processData} />;
|
||||
}
|
||||
|
||||
// 데이터 없음 (should not reach here)
|
||||
return (
|
||||
<ErrorCard
|
||||
type="not-found"
|
||||
title="공정을 찾을 수 없습니다"
|
||||
description="요청하신 공정 정보가 존재하지 않습니다."
|
||||
homeButtonLabel="목록으로 이동"
|
||||
homeButtonHref={BASE_PATH}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
export { default as ProcessListClient } from './ProcessListClient';
|
||||
export { ProcessForm } from './ProcessForm';
|
||||
export { ProcessDetail } from './ProcessDetail';
|
||||
export { ProcessDetailClientV2 } from './ProcessDetailClientV2';
|
||||
export { RuleModal } from './RuleModal';
|
||||
export { ProcessWorkLogPreviewModal } from './ProcessWorkLogPreviewModal';
|
||||
201
src/components/settings/PopupManagement/PopupDetailClientV2.tsx
Normal file
201
src/components/settings/PopupManagement/PopupDetailClientV2.tsx
Normal file
@@ -0,0 +1,201 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 팝업관리 상세 클라이언트 컴포넌트 V2
|
||||
* IntegratedDetailTemplate 기반 마이그레이션
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { format } from 'date-fns';
|
||||
import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate';
|
||||
import type { DetailMode } from '@/components/templates/IntegratedDetailTemplate/types';
|
||||
import type { Popup, PopupFormData } from './types';
|
||||
import { getPopupById, createPopup, updatePopup, deletePopup } from './actions';
|
||||
import { popupDetailConfig } from './popupDetailConfig';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface PopupDetailClientV2Props {
|
||||
popupId?: string;
|
||||
initialMode?: DetailMode;
|
||||
}
|
||||
|
||||
// 현재 로그인 사용자 정보 (실제로는 auth context에서 가져옴)
|
||||
const CURRENT_USER = {
|
||||
id: 'user1',
|
||||
name: '홍길동',
|
||||
};
|
||||
|
||||
export function PopupDetailClientV2({ popupId, initialMode }: PopupDetailClientV2Props) {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
// URL 쿼리에서 모드 결정
|
||||
const modeFromQuery = searchParams.get('mode') as DetailMode | null;
|
||||
const isNewMode = !popupId || popupId === 'new';
|
||||
|
||||
const [mode, setMode] = useState<DetailMode>(() => {
|
||||
if (isNewMode) return 'create';
|
||||
if (initialMode) return initialMode;
|
||||
if (modeFromQuery === 'edit') return 'edit';
|
||||
return 'view';
|
||||
});
|
||||
|
||||
const [popupData, setPopupData] = useState<Popup | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(!isNewMode);
|
||||
|
||||
// 데이터 로드
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
if (isNewMode) {
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const data = await getPopupById(popupId!);
|
||||
if (data) {
|
||||
setPopupData(data);
|
||||
} else {
|
||||
toast.error('팝업을 불러오는데 실패했습니다.');
|
||||
router.push(popupDetailConfig.basePath);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('팝업 조회 실패:', error);
|
||||
toast.error('팝업을 불러오는데 실패했습니다.');
|
||||
router.push(popupDetailConfig.basePath);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadData();
|
||||
}, [popupId, isNewMode, router]);
|
||||
|
||||
// URL 쿼리 변경 감지
|
||||
useEffect(() => {
|
||||
if (!isNewMode && modeFromQuery === 'edit') {
|
||||
setMode('edit');
|
||||
} else if (!isNewMode && !modeFromQuery) {
|
||||
setMode('view');
|
||||
}
|
||||
}, [modeFromQuery, isNewMode]);
|
||||
|
||||
// 모드 변경 핸들러
|
||||
const handleModeChange = useCallback(
|
||||
(newMode: DetailMode) => {
|
||||
setMode(newMode);
|
||||
if (newMode === 'edit' && popupId) {
|
||||
router.push(`${popupDetailConfig.basePath}/${popupId}?mode=edit`);
|
||||
} else if (newMode === 'view' && popupId) {
|
||||
router.push(`${popupDetailConfig.basePath}/${popupId}`);
|
||||
}
|
||||
},
|
||||
[router, popupId]
|
||||
);
|
||||
|
||||
// 저장 핸들러
|
||||
const handleSubmit = useCallback(
|
||||
async (formData: Record<string, unknown>) => {
|
||||
try {
|
||||
const popupFormData: PopupFormData = {
|
||||
target: (formData.target as PopupFormData['target']) || 'all',
|
||||
title: formData.title as string,
|
||||
content: formData.content as string,
|
||||
status: (formData.status as PopupFormData['status']) || 'inactive',
|
||||
startDate: formData.startDate as string,
|
||||
endDate: formData.endDate as string,
|
||||
};
|
||||
|
||||
if (isNewMode) {
|
||||
const result = await createPopup(popupFormData);
|
||||
if (result.success) {
|
||||
toast.success('팝업이 등록되었습니다.');
|
||||
router.push(popupDetailConfig.basePath);
|
||||
return { success: true };
|
||||
}
|
||||
return { success: false, error: result.error || '팝업 등록에 실패했습니다.' };
|
||||
} else {
|
||||
const result = await updatePopup(popupId!, popupFormData);
|
||||
if (result.success) {
|
||||
toast.success('팝업이 수정되었습니다.');
|
||||
router.push(`${popupDetailConfig.basePath}/${popupId}`);
|
||||
return { success: true };
|
||||
}
|
||||
return { success: false, error: result.error || '팝업 수정에 실패했습니다.' };
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('저장 실패:', error);
|
||||
return { success: false, error: error instanceof Error ? error.message : '저장 중 오류가 발생했습니다.' };
|
||||
}
|
||||
},
|
||||
[isNewMode, popupId, router]
|
||||
);
|
||||
|
||||
// 삭제 핸들러
|
||||
const handleDelete = useCallback(
|
||||
async (id: string | number) => {
|
||||
try {
|
||||
const result = await deletePopup(String(id));
|
||||
if (result.success) {
|
||||
toast.success('팝업이 삭제되었습니다.');
|
||||
router.push(popupDetailConfig.basePath);
|
||||
return { success: true };
|
||||
}
|
||||
return { success: false, error: result.error || '팝업 삭제에 실패했습니다.' };
|
||||
} catch (error) {
|
||||
console.error('삭제 실패:', error);
|
||||
return { success: false, error: error instanceof Error ? error.message : '삭제 중 오류가 발생했습니다.' };
|
||||
}
|
||||
},
|
||||
[router]
|
||||
);
|
||||
|
||||
// 취소 핸들러
|
||||
const handleCancel = useCallback(() => {
|
||||
if (isNewMode) {
|
||||
router.push(popupDetailConfig.basePath);
|
||||
} else {
|
||||
setMode('view');
|
||||
router.push(`${popupDetailConfig.basePath}/${popupId}`);
|
||||
}
|
||||
}, [router, popupId, isNewMode]);
|
||||
|
||||
// 초기 데이터 (신규 등록 시 기본값 포함)
|
||||
const initialData = isNewMode
|
||||
? ({
|
||||
target: 'all',
|
||||
status: 'inactive',
|
||||
author: CURRENT_USER.name,
|
||||
createdAt: format(new Date(), 'yyyy-MM-dd HH:mm'),
|
||||
startDate: format(new Date(), 'yyyy-MM-dd'),
|
||||
endDate: format(new Date(), 'yyyy-MM-dd'),
|
||||
} as unknown as Popup)
|
||||
: popupData || undefined;
|
||||
|
||||
// 타이틀 동적 설정
|
||||
const dynamicConfig = {
|
||||
...popupDetailConfig,
|
||||
title:
|
||||
mode === 'create'
|
||||
? '팝업관리'
|
||||
: mode === 'edit'
|
||||
? popupData?.title || '팝업관리'
|
||||
: popupData?.title || '팝업관리 상세',
|
||||
};
|
||||
|
||||
return (
|
||||
<IntegratedDetailTemplate
|
||||
config={dynamicConfig}
|
||||
mode={mode}
|
||||
initialData={initialData}
|
||||
itemId={popupId}
|
||||
isLoading={isLoading}
|
||||
onSubmit={handleSubmit}
|
||||
onDelete={handleDelete}
|
||||
onCancel={handleCancel}
|
||||
onModeChange={handleModeChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
export { PopupList } from './PopupList';
|
||||
export { PopupForm } from './PopupForm';
|
||||
export { PopupDetail } from './PopupDetail';
|
||||
export { PopupDetailClientV2 } from './PopupDetailClientV2';
|
||||
export * from './types';
|
||||
191
src/components/settings/PopupManagement/popupDetailConfig.ts
Normal file
191
src/components/settings/PopupManagement/popupDetailConfig.ts
Normal file
@@ -0,0 +1,191 @@
|
||||
/**
|
||||
* 팝업관리 상세 페이지 설정
|
||||
* IntegratedDetailTemplate V2 마이그레이션
|
||||
*/
|
||||
|
||||
import { Megaphone } from 'lucide-react';
|
||||
import type { DetailConfig, FieldDefinition, SectionDefinition } from '@/components/templates/IntegratedDetailTemplate/types';
|
||||
import type { Popup, PopupFormData, PopupTarget, PopupStatus } from './types';
|
||||
import { RichTextEditor } from '@/components/board/RichTextEditor';
|
||||
import { createElement } from 'react';
|
||||
|
||||
// ===== 대상 옵션 =====
|
||||
const TARGET_OPTIONS = [
|
||||
{ value: 'all', label: '전사' },
|
||||
{ value: 'department', label: '부서별' },
|
||||
];
|
||||
|
||||
// ===== 상태 옵션 =====
|
||||
const STATUS_OPTIONS = [
|
||||
{ value: 'inactive', label: '사용안함' },
|
||||
{ value: 'active', label: '사용함' },
|
||||
];
|
||||
|
||||
// ===== 필드 정의 =====
|
||||
export const popupFields: FieldDefinition[] = [
|
||||
{
|
||||
key: 'target',
|
||||
label: '대상',
|
||||
type: 'select',
|
||||
required: true,
|
||||
options: TARGET_OPTIONS,
|
||||
placeholder: '대상을 선택해주세요',
|
||||
validation: [
|
||||
{ type: 'required', message: '대상을 선택해주세요.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'startDate',
|
||||
label: '시작일',
|
||||
type: 'date',
|
||||
required: true,
|
||||
validation: [
|
||||
{ type: 'required', message: '시작일을 선택해주세요.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'endDate',
|
||||
label: '종료일',
|
||||
type: 'date',
|
||||
required: true,
|
||||
validation: [
|
||||
{ type: 'required', message: '종료일을 선택해주세요.' },
|
||||
{
|
||||
type: 'custom',
|
||||
message: '종료일은 시작일 이후여야 합니다.',
|
||||
validate: (value, formData) => {
|
||||
const startDate = formData.startDate as string;
|
||||
const endDate = value as string;
|
||||
if (!startDate || !endDate) return true;
|
||||
return endDate >= startDate;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'title',
|
||||
label: '제목',
|
||||
type: 'text',
|
||||
required: true,
|
||||
placeholder: '제목을 입력해주세요',
|
||||
gridSpan: 2,
|
||||
validation: [
|
||||
{ type: 'required', message: '제목을 입력해주세요.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'content',
|
||||
label: '내용',
|
||||
type: 'custom',
|
||||
required: true,
|
||||
gridSpan: 2,
|
||||
validation: [
|
||||
{
|
||||
type: 'custom',
|
||||
message: '내용을 입력해주세요.',
|
||||
validate: (value) => {
|
||||
const content = value as string;
|
||||
return !!content && content.trim() !== '' && content !== '<p></p>';
|
||||
},
|
||||
},
|
||||
],
|
||||
renderField: ({ value, onChange, mode, disabled }) => {
|
||||
if (mode === 'view') {
|
||||
// View 모드: HTML 렌더링
|
||||
return createElement('div', {
|
||||
className: 'border border-gray-200 rounded-md p-4 bg-gray-50 min-h-[100px] prose prose-sm max-w-none',
|
||||
dangerouslySetInnerHTML: { __html: (value as string) || '' },
|
||||
});
|
||||
}
|
||||
// Edit/Create 모드: RichTextEditor
|
||||
return createElement(RichTextEditor, {
|
||||
value: (value as string) || '',
|
||||
onChange: onChange,
|
||||
placeholder: '내용을 입력해주세요',
|
||||
minHeight: '200px',
|
||||
disabled: disabled,
|
||||
});
|
||||
},
|
||||
formatValue: (value) => {
|
||||
if (!value) return '-';
|
||||
return createElement('div', {
|
||||
className: 'border border-gray-200 rounded-md p-4 bg-gray-50 min-h-[100px] prose prose-sm max-w-none',
|
||||
dangerouslySetInnerHTML: { __html: value as string },
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
label: '상태',
|
||||
type: 'radio',
|
||||
options: STATUS_OPTIONS,
|
||||
defaultValue: 'inactive',
|
||||
},
|
||||
{
|
||||
key: 'author',
|
||||
label: '작성자',
|
||||
type: 'text',
|
||||
disabled: true,
|
||||
hideInForm: false,
|
||||
},
|
||||
{
|
||||
key: 'createdAt',
|
||||
label: '등록일시',
|
||||
type: 'text',
|
||||
disabled: true,
|
||||
hideInForm: false,
|
||||
},
|
||||
];
|
||||
|
||||
// ===== 섹션 정의 =====
|
||||
export const popupSections: SectionDefinition[] = [
|
||||
{
|
||||
id: 'basicInfo',
|
||||
title: '팝업 정보',
|
||||
description: '팝업의 기본 정보를 입력해주세요',
|
||||
fields: ['target', 'startDate', 'endDate', 'title', 'content', 'status', 'author', 'createdAt'],
|
||||
},
|
||||
];
|
||||
|
||||
// ===== 설정 =====
|
||||
export const popupDetailConfig: DetailConfig<Popup> = {
|
||||
title: '팝업관리',
|
||||
description: '팝업 목록을 관리합니다',
|
||||
icon: Megaphone,
|
||||
basePath: '/ko/settings/popup-management',
|
||||
fields: popupFields,
|
||||
sections: popupSections,
|
||||
gridColumns: 2,
|
||||
actions: {
|
||||
submitLabel: '저장',
|
||||
cancelLabel: '취소',
|
||||
showDelete: true,
|
||||
deleteLabel: '삭제',
|
||||
showEdit: true,
|
||||
editLabel: '수정',
|
||||
showBack: true,
|
||||
backLabel: '목록',
|
||||
deleteConfirmMessage: {
|
||||
title: '팝업 삭제',
|
||||
description: '이 팝업을 삭제하시겠습니까? 삭제된 데이터는 복구할 수 없습니다.',
|
||||
},
|
||||
},
|
||||
transformInitialData: (data: Popup) => ({
|
||||
target: data.target || 'all',
|
||||
startDate: data.startDate || '',
|
||||
endDate: data.endDate || '',
|
||||
title: data.title || '',
|
||||
content: data.content || '',
|
||||
status: data.status || 'inactive',
|
||||
author: data.author || '',
|
||||
createdAt: data.createdAt || '',
|
||||
}),
|
||||
transformSubmitData: (formData): Partial<PopupFormData> => ({
|
||||
target: formData.target as PopupTarget,
|
||||
title: formData.title as string,
|
||||
content: formData.content as string,
|
||||
status: formData.status as PopupStatus,
|
||||
startDate: formData.startDate as string,
|
||||
endDate: formData.endDate as string,
|
||||
}),
|
||||
};
|
||||
283
src/components/templates/IntegratedDetailTemplate/FieldInput.tsx
Normal file
283
src/components/templates/IntegratedDetailTemplate/FieldInput.tsx
Normal file
@@ -0,0 +1,283 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* FieldInput - 순수 입력 컴포넌트 렌더러
|
||||
*
|
||||
* 라벨, 에러 메시지 없이 순수 입력 컴포넌트만 반환
|
||||
* 레이아웃(라벨, 에러, description)은 DetailField가 담당
|
||||
*/
|
||||
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { FieldDefinition, DetailMode, FieldOption } from './types';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
export interface FieldInputProps {
|
||||
field: FieldDefinition;
|
||||
value: unknown;
|
||||
onChange: (value: unknown) => void;
|
||||
mode: DetailMode;
|
||||
error?: string;
|
||||
dynamicOptions?: FieldOption[];
|
||||
}
|
||||
|
||||
export function FieldInput({
|
||||
field,
|
||||
value,
|
||||
onChange,
|
||||
mode,
|
||||
error,
|
||||
dynamicOptions,
|
||||
}: FieldInputProps) {
|
||||
const isViewMode = mode === 'view';
|
||||
const isDisabled =
|
||||
field.readonly ||
|
||||
(typeof field.disabled === 'function'
|
||||
? field.disabled(mode)
|
||||
: field.disabled);
|
||||
|
||||
// 옵션 (동적 로드된 옵션 우선)
|
||||
const options = dynamicOptions || field.options || [];
|
||||
|
||||
// View 모드: 값만 표시 (라벨 없이)
|
||||
if (isViewMode) {
|
||||
return (
|
||||
<div className="text-sm">
|
||||
{renderViewValue(field, value, options)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Form 모드: 입력 필드만 반환 (라벨, 에러 없이)
|
||||
return renderFormField(field, value, onChange, isDisabled, options, error);
|
||||
}
|
||||
|
||||
// View 모드 값 렌더링
|
||||
function renderViewValue(
|
||||
field: FieldDefinition,
|
||||
value: unknown,
|
||||
options: FieldOption[]
|
||||
): ReactNode {
|
||||
// 커스텀 포맷터가 있으면 사용
|
||||
if (field.formatValue) {
|
||||
return field.formatValue(value);
|
||||
}
|
||||
|
||||
// 값이 없으면 '-' 표시
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return '-';
|
||||
}
|
||||
|
||||
switch (field.type) {
|
||||
case 'password':
|
||||
return '****';
|
||||
|
||||
case 'select':
|
||||
case 'radio': {
|
||||
const option = options.find((opt) => opt.value === value);
|
||||
return option?.label || String(value);
|
||||
}
|
||||
|
||||
case 'checkbox':
|
||||
return value ? '예' : '아니오';
|
||||
|
||||
case 'date':
|
||||
if (typeof value === 'string') {
|
||||
try {
|
||||
return new Date(value).toLocaleDateString('ko-KR');
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return String(value);
|
||||
|
||||
case 'textarea':
|
||||
return (
|
||||
<div className="whitespace-pre-wrap">{String(value)}</div>
|
||||
);
|
||||
|
||||
default:
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
// Form 모드 필드 렌더링 (입력 컴포넌트만)
|
||||
function renderFormField(
|
||||
field: FieldDefinition,
|
||||
value: unknown,
|
||||
onChange: (value: unknown) => void,
|
||||
disabled: boolean,
|
||||
options: FieldOption[],
|
||||
error?: string
|
||||
): ReactNode {
|
||||
const stringValue = value !== null && value !== undefined ? String(value) : '';
|
||||
const hasError = !!error;
|
||||
|
||||
switch (field.type) {
|
||||
case 'text':
|
||||
case 'email':
|
||||
case 'tel':
|
||||
return (
|
||||
<Input
|
||||
id={field.key}
|
||||
type={field.type}
|
||||
value={stringValue}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={field.placeholder}
|
||||
disabled={disabled}
|
||||
className={cn(hasError && 'border-destructive')}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'number':
|
||||
return (
|
||||
<Input
|
||||
id={field.key}
|
||||
type="number"
|
||||
value={stringValue}
|
||||
onChange={(e) => onChange(e.target.value ? Number(e.target.value) : '')}
|
||||
placeholder={field.placeholder}
|
||||
disabled={disabled}
|
||||
className={cn(hasError && 'border-destructive')}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'password':
|
||||
return (
|
||||
<Input
|
||||
id={field.key}
|
||||
type="password"
|
||||
value={stringValue}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={field.placeholder || '****'}
|
||||
disabled={disabled}
|
||||
className={cn(hasError && 'border-destructive')}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'textarea':
|
||||
return (
|
||||
<Textarea
|
||||
id={field.key}
|
||||
value={stringValue}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={field.placeholder}
|
||||
disabled={disabled}
|
||||
className={cn(hasError && 'border-destructive')}
|
||||
rows={4}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'select':
|
||||
return (
|
||||
<Select
|
||||
key={`${field.key}-${stringValue}`}
|
||||
value={stringValue}
|
||||
onValueChange={onChange}
|
||||
disabled={disabled}
|
||||
>
|
||||
<SelectTrigger className={cn(hasError && 'border-destructive')}>
|
||||
<SelectValue placeholder={field.placeholder || '선택하세요'} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{options.map((option) => (
|
||||
<SelectItem
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
disabled={option.disabled}
|
||||
>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
|
||||
case 'radio':
|
||||
return (
|
||||
<RadioGroup
|
||||
value={stringValue}
|
||||
onValueChange={onChange}
|
||||
disabled={disabled}
|
||||
className="flex flex-wrap gap-4"
|
||||
>
|
||||
{options.map((option) => (
|
||||
<div key={option.value} className="flex items-center space-x-2">
|
||||
<RadioGroupItem value={option.value} id={`${field.key}-${option.value}`} />
|
||||
<Label
|
||||
htmlFor={`${field.key}-${option.value}`}
|
||||
className="font-normal cursor-pointer"
|
||||
>
|
||||
{option.label}
|
||||
</Label>
|
||||
</div>
|
||||
))}
|
||||
</RadioGroup>
|
||||
);
|
||||
|
||||
case 'checkbox':
|
||||
return (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id={field.key}
|
||||
checked={!!value}
|
||||
onCheckedChange={onChange}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Label
|
||||
htmlFor={field.key}
|
||||
className="font-normal cursor-pointer"
|
||||
>
|
||||
{field.placeholder || '동의'}
|
||||
</Label>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'date':
|
||||
return (
|
||||
<Input
|
||||
id={field.key}
|
||||
type="date"
|
||||
value={stringValue}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
disabled={disabled}
|
||||
className={cn(hasError && 'border-destructive')}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'custom':
|
||||
if (field.renderField) {
|
||||
return field.renderField({
|
||||
value,
|
||||
onChange,
|
||||
mode: 'edit',
|
||||
disabled,
|
||||
});
|
||||
}
|
||||
return <div className="text-muted-foreground">커스텀 렌더러가 필요합니다</div>;
|
||||
|
||||
default:
|
||||
return (
|
||||
<Input
|
||||
id={field.key}
|
||||
value={stringValue}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={field.placeholder}
|
||||
disabled={disabled}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default FieldInput;
|
||||
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* DetailActions - 상세 페이지 버튼 영역 컴포넌트
|
||||
*
|
||||
* View 모드: 목록으로 | [추가액션] 삭제 | 수정
|
||||
* Form 모드: 취소 | 저장/등록
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import type { ReactNode } from 'react';
|
||||
import { ArrowLeft, Save, Trash2, X, Edit } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface DetailActionsProps {
|
||||
/** 현재 모드 */
|
||||
mode: 'view' | 'edit' | 'create';
|
||||
/** 제출 중 여부 */
|
||||
isSubmitting?: boolean;
|
||||
/** 권한 */
|
||||
permissions?: {
|
||||
canEdit?: boolean;
|
||||
canDelete?: boolean;
|
||||
};
|
||||
/** 버튼 표시 설정 */
|
||||
showButtons?: {
|
||||
back?: boolean;
|
||||
delete?: boolean;
|
||||
edit?: boolean;
|
||||
};
|
||||
/** 버튼 라벨 */
|
||||
labels?: {
|
||||
back?: string;
|
||||
cancel?: string;
|
||||
delete?: string;
|
||||
edit?: string;
|
||||
submit?: string;
|
||||
};
|
||||
/** 핸들러 */
|
||||
onBack?: () => void;
|
||||
onCancel?: () => void;
|
||||
onDelete?: () => void;
|
||||
onEdit?: () => void;
|
||||
onSubmit?: () => void;
|
||||
/** 추가 액션 (view 모드에서 삭제 버튼 앞에 표시) */
|
||||
extraActions?: ReactNode;
|
||||
/** 추가 클래스 */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function DetailActions({
|
||||
mode,
|
||||
isSubmitting = false,
|
||||
permissions = {},
|
||||
showButtons = {},
|
||||
labels = {},
|
||||
onBack,
|
||||
onCancel,
|
||||
onDelete,
|
||||
onEdit,
|
||||
onSubmit,
|
||||
extraActions,
|
||||
className,
|
||||
}: DetailActionsProps) {
|
||||
const isViewMode = mode === 'view';
|
||||
const isCreateMode = mode === 'create';
|
||||
|
||||
const {
|
||||
canEdit = true,
|
||||
canDelete = true,
|
||||
} = permissions;
|
||||
|
||||
const {
|
||||
back: showBack = true,
|
||||
delete: showDelete = true,
|
||||
edit: showEdit = true,
|
||||
} = showButtons;
|
||||
|
||||
const {
|
||||
back: backLabel = '목록으로',
|
||||
cancel: cancelLabel = '취소',
|
||||
delete: deleteLabel = '삭제',
|
||||
edit: editLabel = '수정',
|
||||
submit: submitLabel,
|
||||
} = labels;
|
||||
|
||||
// 실제 submit 라벨 (create 모드면 '등록', 아니면 '저장')
|
||||
const actualSubmitLabel = submitLabel || (isCreateMode ? '등록' : '저장');
|
||||
|
||||
if (isViewMode) {
|
||||
return (
|
||||
<div className={cn('flex items-center justify-between', className)}>
|
||||
{/* 왼쪽: 목록으로 */}
|
||||
{showBack && onBack ? (
|
||||
<Button variant="outline" onClick={onBack}>
|
||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||
{backLabel}
|
||||
</Button>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
|
||||
{/* 오른쪽: 추가액션 + 삭제 + 수정 */}
|
||||
<div className="flex items-center gap-2">
|
||||
{extraActions}
|
||||
{canDelete && showDelete && onDelete && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onDelete}
|
||||
className="text-destructive hover:bg-destructive hover:text-destructive-foreground"
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
{deleteLabel}
|
||||
</Button>
|
||||
)}
|
||||
{canEdit && showEdit && onEdit && (
|
||||
<Button onClick={onEdit}>
|
||||
<Edit className="w-4 h-4 mr-2" />
|
||||
{editLabel}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Form 모드 (edit/create)
|
||||
return (
|
||||
<div className={cn('flex items-center justify-between', className)}>
|
||||
{/* 왼쪽: 취소 */}
|
||||
<Button variant="outline" onClick={onCancel} disabled={isSubmitting}>
|
||||
<X className="w-4 h-4 mr-2" />
|
||||
{cancelLabel}
|
||||
</Button>
|
||||
|
||||
{/* 오른쪽: 저장/등록 */}
|
||||
<Button onClick={onSubmit} disabled={isSubmitting}>
|
||||
<Save className="w-4 h-4 mr-2" />
|
||||
{actualSubmitLabel}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default DetailActions;
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* DetailField - 상세 페이지 필드 레이아웃 컴포넌트
|
||||
*
|
||||
* 라벨, 필수 마크, 에러 메시지, 설명을 포함한 필드 래퍼
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import type { ReactNode } from 'react';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface DetailFieldProps {
|
||||
/** 필드 라벨 */
|
||||
label: string;
|
||||
/** 필수 여부 (default: false) */
|
||||
required?: boolean;
|
||||
/** 에러 메시지 */
|
||||
error?: string;
|
||||
/** 설명 텍스트 */
|
||||
description?: string;
|
||||
/** 그리드 span (1~4) */
|
||||
colSpan?: 1 | 2 | 3 | 4;
|
||||
/** 필드 내용 (Input, Select 등) */
|
||||
children: ReactNode;
|
||||
/** 추가 클래스 */
|
||||
className?: string;
|
||||
/** 라벨 숨김 (시각적으로만) */
|
||||
hideLabel?: boolean;
|
||||
/** HTML for 속성 연결용 ID */
|
||||
htmlFor?: string;
|
||||
/** 모드 - view 모드에서는 필수마크/에러/description 숨김 */
|
||||
mode?: 'view' | 'edit' | 'create';
|
||||
}
|
||||
|
||||
// colSpan에 따른 그리드 클래스
|
||||
const colSpanClasses = {
|
||||
1: '',
|
||||
2: 'md:col-span-2',
|
||||
3: 'md:col-span-2 lg:col-span-3',
|
||||
4: 'md:col-span-2 lg:col-span-4',
|
||||
};
|
||||
|
||||
export function DetailField({
|
||||
label,
|
||||
required = false,
|
||||
error,
|
||||
description,
|
||||
colSpan = 1,
|
||||
children,
|
||||
className,
|
||||
hideLabel = false,
|
||||
htmlFor,
|
||||
mode,
|
||||
}: DetailFieldProps) {
|
||||
const isViewMode = mode === 'view';
|
||||
|
||||
return (
|
||||
<div className={cn('space-y-2', colSpanClasses[colSpan], className)}>
|
||||
{/* 라벨 영역 */}
|
||||
<Label
|
||||
htmlFor={htmlFor}
|
||||
className={cn(
|
||||
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
|
||||
isViewMode && 'text-muted-foreground',
|
||||
hideLabel && 'sr-only'
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
{/* View 모드에서는 필수마크 숨김 */}
|
||||
{required && !isViewMode && <span className="text-destructive ml-1">*</span>}
|
||||
</Label>
|
||||
|
||||
{/* 필드 내용 */}
|
||||
{children}
|
||||
|
||||
{/* 에러 메시지 - View 모드에서는 숨김 */}
|
||||
{error && !isViewMode && (
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
)}
|
||||
|
||||
{/* 설명 텍스트 - View 모드에서는 숨김 */}
|
||||
{description && !error && !isViewMode && (
|
||||
<p className="text-sm text-muted-foreground">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default DetailField;
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* DetailGrid - 상세 페이지 반응형 그리드 컴포넌트
|
||||
*
|
||||
* 1~4열 반응형 그리드 레이아웃 제공
|
||||
* 모바일에서는 자동으로 1열로 변환
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import type { ReactNode } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface DetailGridProps {
|
||||
/** 그리드 열 수 (default: 2) */
|
||||
cols?: 1 | 2 | 3 | 4;
|
||||
/** 그리드 간격 (default: 'md') */
|
||||
gap?: 'sm' | 'md' | 'lg';
|
||||
/** 그리드 내용 */
|
||||
children: ReactNode;
|
||||
/** 추가 클래스 */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// 열 수에 따른 그리드 클래스
|
||||
const colsClasses = {
|
||||
1: 'grid-cols-1',
|
||||
2: 'grid-cols-1 md:grid-cols-2',
|
||||
3: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3',
|
||||
4: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-4',
|
||||
};
|
||||
|
||||
// 간격에 따른 gap 클래스
|
||||
const gapClasses = {
|
||||
sm: 'gap-4',
|
||||
md: 'gap-6',
|
||||
lg: 'gap-8',
|
||||
};
|
||||
|
||||
export function DetailGrid({
|
||||
cols = 2,
|
||||
gap = 'md',
|
||||
children,
|
||||
className,
|
||||
}: DetailGridProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'grid',
|
||||
colsClasses[cols],
|
||||
gapClasses[gap],
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default DetailGrid;
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* DetailSection - 상세 페이지 섹션 래퍼 컴포넌트
|
||||
*
|
||||
* Card 기반의 섹션 컨테이너로 제목, 설명, 접기/펼치기 기능 제공
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState, type ReactNode } from 'react';
|
||||
import { ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface DetailSectionProps {
|
||||
/** 섹션 제목 */
|
||||
title: string;
|
||||
/** 섹션 설명 (선택) */
|
||||
description?: string;
|
||||
/** 섹션 내용 */
|
||||
children: ReactNode;
|
||||
/** 접기/펼치기 가능 여부 (default: false) */
|
||||
collapsible?: boolean;
|
||||
/** 기본 펼침 상태 (default: true) */
|
||||
defaultOpen?: boolean;
|
||||
/** 추가 클래스 */
|
||||
className?: string;
|
||||
/** 헤더 우측 액션 영역 */
|
||||
headerActions?: ReactNode;
|
||||
}
|
||||
|
||||
export function DetailSection({
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
collapsible = false,
|
||||
defaultOpen = true,
|
||||
className,
|
||||
headerActions,
|
||||
}: DetailSectionProps) {
|
||||
const [isOpen, setIsOpen] = useState(defaultOpen);
|
||||
|
||||
const handleToggle = () => {
|
||||
if (collapsible) {
|
||||
setIsOpen(!isOpen);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className={cn('', className)}>
|
||||
<CardHeader
|
||||
className={cn(
|
||||
'pb-4',
|
||||
collapsible && 'cursor-pointer select-none'
|
||||
)}
|
||||
onClick={collapsible ? handleToggle : undefined}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
{title}
|
||||
{collapsible && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleToggle();
|
||||
}}
|
||||
>
|
||||
{isOpen ? (
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</CardTitle>
|
||||
{description && (
|
||||
<p className="text-sm text-muted-foreground mt-1">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
{headerActions && (
|
||||
<div onClick={(e) => e.stopPropagation()}>{headerActions}</div>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
{(!collapsible || isOpen) && (
|
||||
<CardContent className="pt-0">{children}</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default DetailSection;
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* IntegratedDetailTemplate Components Index
|
||||
*
|
||||
* 상세 페이지 내부 컴포넌트 통합 export
|
||||
*/
|
||||
|
||||
// 메인 컴포넌트
|
||||
export { DetailSection, type DetailSectionProps } from './DetailSection';
|
||||
export { DetailGrid, type DetailGridProps } from './DetailGrid';
|
||||
export { DetailField, type DetailFieldProps } from './DetailField';
|
||||
export { DetailActions, type DetailActionsProps } from './DetailActions';
|
||||
|
||||
// 스켈레톤 컴포넌트
|
||||
export {
|
||||
DetailFieldSkeleton,
|
||||
DetailGridSkeleton,
|
||||
DetailSectionSkeleton,
|
||||
type DetailFieldSkeletonProps,
|
||||
type DetailGridSkeletonProps,
|
||||
type DetailSectionSkeletonProps,
|
||||
} from './skeletons';
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* DetailFieldSkeleton - 필드 로딩 스켈레톤 컴포넌트
|
||||
*
|
||||
* 개별 필드의 로딩 상태를 표시
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface DetailFieldSkeletonProps {
|
||||
/** 그리드 span (1~4) */
|
||||
colSpan?: 1 | 2 | 3 | 4;
|
||||
/** 라벨 너비 (default: 'w-20') */
|
||||
labelWidth?: string;
|
||||
/** 입력 높이 (default: 'h-10') */
|
||||
inputHeight?: string;
|
||||
/** 추가 클래스 */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// colSpan에 따른 그리드 클래스
|
||||
const colSpanClasses = {
|
||||
1: '',
|
||||
2: 'md:col-span-2',
|
||||
3: 'md:col-span-2 lg:col-span-3',
|
||||
4: 'md:col-span-2 lg:col-span-4',
|
||||
};
|
||||
|
||||
export function DetailFieldSkeleton({
|
||||
colSpan = 1,
|
||||
labelWidth = 'w-20',
|
||||
inputHeight = 'h-10',
|
||||
className,
|
||||
}: DetailFieldSkeletonProps) {
|
||||
return (
|
||||
<div className={cn('space-y-2', colSpanClasses[colSpan], className)}>
|
||||
{/* 라벨 스켈레톤 */}
|
||||
<Skeleton className={cn('h-4', labelWidth)} />
|
||||
{/* 입력 필드 스켈레톤 */}
|
||||
<Skeleton className={cn('w-full', inputHeight)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default DetailFieldSkeleton;
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* DetailGridSkeleton - 그리드 로딩 스켈레톤 컴포넌트
|
||||
*
|
||||
* 여러 필드의 그리드 로딩 상태를 표시
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { DetailFieldSkeleton } from './DetailFieldSkeleton';
|
||||
|
||||
export interface DetailGridSkeletonProps {
|
||||
/** 그리드 열 수 (default: 2) */
|
||||
cols?: 1 | 2 | 3 | 4;
|
||||
/** 필드 개수 (default: 6) */
|
||||
fieldCount?: number;
|
||||
/** 그리드 간격 (default: 'md') */
|
||||
gap?: 'sm' | 'md' | 'lg';
|
||||
/** 추가 클래스 */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// 열 수에 따른 그리드 클래스
|
||||
const colsClasses = {
|
||||
1: 'grid-cols-1',
|
||||
2: 'grid-cols-1 md:grid-cols-2',
|
||||
3: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3',
|
||||
4: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-4',
|
||||
};
|
||||
|
||||
// 간격에 따른 gap 클래스
|
||||
const gapClasses = {
|
||||
sm: 'gap-4',
|
||||
md: 'gap-6',
|
||||
lg: 'gap-8',
|
||||
};
|
||||
|
||||
export function DetailGridSkeleton({
|
||||
cols = 2,
|
||||
fieldCount = 6,
|
||||
gap = 'md',
|
||||
className,
|
||||
}: DetailGridSkeletonProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'grid',
|
||||
colsClasses[cols],
|
||||
gapClasses[gap],
|
||||
className
|
||||
)}
|
||||
>
|
||||
{Array.from({ length: fieldCount }).map((_, index) => (
|
||||
<DetailFieldSkeleton key={index} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default DetailGridSkeleton;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user