feat(WEB): 견적서 V2 컴포넌트 개선 및 미리보기 모달 패턴 적용
- LocationDetailPanel: 6개 탭 구현 (본체, 가이드레일, 케이스, 하단마감재, 모터&제어기, 부자재) - 각 탭별 다른 테이블 컬럼 구조 적용 - QuoteSummaryPanel: 개소별/상세별 합계 패널 개선 - QuotePreviewModal: EstimateDocumentModal 패턴 적용 (헤더+버튼 영역 분리) - Input value → defaultValue 변경으로 React 경고 해결 - 팩스/카카오톡 버튼 제거 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
# 권한 관리 시스템 현황 분석
|
||||
|
||||
> 작성일: 2026-01-07
|
||||
> 최종 수정일: 2026-01-12
|
||||
> 목적: SAM 프로젝트 권한 시스템 현황 파악 및 향후 구현 계획 정리
|
||||
|
||||
---
|
||||
@@ -10,13 +11,13 @@
|
||||
| 구분 | 상태 | 설명 |
|
||||
|------|------|------|
|
||||
| 권한 설정 UI | ✅ 완성 | `/settings/permissions/[id]`에서 역할별 권한 설정 가능 |
|
||||
| 백엔드 권한 API | ✅ 존재 | 권한 매트릭스 조회/설정 API 구현됨 |
|
||||
| 백엔드 권한 API | ✅ 완성 | 권한 매트릭스 조회/설정 API 구현됨 |
|
||||
| 백엔드 API 권한 체크 | ⚠️ 구조만 있음 | 미들웨어 존재하나 라우트에 미적용 |
|
||||
| 프론트 권한 체크 | ❌ 미구현 | 권한 매트릭스 조회 및 UI 제어 로직 없음 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 권한 타입 (7가지)
|
||||
## 2. 권한 타입 (5가지)
|
||||
|
||||
| 권한 | 영문 | 적용 대상 |
|
||||
|------|------|----------|
|
||||
@@ -25,8 +26,8 @@
|
||||
| 수정 | `update` | 수정 버튼 |
|
||||
| 삭제 | `delete` | 삭제 버튼 |
|
||||
| 승인 | `approve` | 승인/반려 버튼 |
|
||||
| 내보내기 | `export` | Excel 다운로드 등 |
|
||||
| 관리 | `manage` | 관리자 전용 기능 |
|
||||
|
||||
> ⚠️ **참고**: `export`, `manage` 권한은 백엔드에 미구현 상태
|
||||
|
||||
---
|
||||
|
||||
@@ -51,32 +52,91 @@
|
||||
|
||||
### 3.2 권한 매트릭스 조회 API
|
||||
|
||||
**사용자별 권한 조회**:
|
||||
**사용자별 권한 조회** (프론트엔드에서 사용):
|
||||
```
|
||||
GET /api/v1/permissions/users/{userId}/menu-matrix
|
||||
```
|
||||
|
||||
**응답 구조**:
|
||||
**실제 응답 구조**:
|
||||
```json
|
||||
{
|
||||
"permission_types": ["view", "create", "update", "delete", "approve", "export", "manage"],
|
||||
"permissions": {
|
||||
"1": { "view": true, "create": true, "update": false, ... },
|
||||
"2": { "view": true, "create": false, ... }
|
||||
"success": true,
|
||||
"message": "유저 메뉴 권한 매트릭스 조회 성공",
|
||||
"data": {
|
||||
"actions": ["view", "create", "update", "delete", "approve"],
|
||||
"tree": [
|
||||
{
|
||||
"menu_id": 1,
|
||||
"parent_id": null,
|
||||
"name": "대시보드",
|
||||
"url": "/dashboard",
|
||||
"type": "system",
|
||||
"children": [
|
||||
{
|
||||
"menu_id": 2,
|
||||
"parent_id": 1,
|
||||
"name": "CEO 대시보드",
|
||||
"url": "/dashboard/ceo",
|
||||
"children": [],
|
||||
"actions": { ... }
|
||||
}
|
||||
],
|
||||
"actions": {
|
||||
"view": {
|
||||
"permission_id": 123,
|
||||
"permission_code": "menu:1.view",
|
||||
"guard_name": "api",
|
||||
"state": "allow",
|
||||
"is_allowed": 1
|
||||
},
|
||||
"create": {
|
||||
"permission_id": 124,
|
||||
"permission_code": "menu:1.create",
|
||||
"guard_name": "api",
|
||||
"state": "deny",
|
||||
"is_allowed": 0
|
||||
},
|
||||
"update": null,
|
||||
"delete": null,
|
||||
"approve": null
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3.3 기타 권한 API
|
||||
**권한 상태 값**:
|
||||
| state | is_allowed | 의미 |
|
||||
|-------|------------|------|
|
||||
| `allow` | 1 | 권한 허용됨 |
|
||||
| `deny` | 0 | 권한 명시적 거부 |
|
||||
| `none` | 0 | 권한 미설정 (기본 거부) |
|
||||
|
||||
| 엔드포인트 | 설명 |
|
||||
|-----------|------|
|
||||
| `GET /api/v1/permissions/departments/{dept_id}/menu-matrix` | 부서별 권한 매트릭스 |
|
||||
| `GET /api/v1/permissions/roles/{role_id}/menu-matrix` | 역할별 권한 매트릭스 |
|
||||
| `GET /api/v1/roles/{id}/permissions/matrix` | 역할 권한 매트릭스 (설정 UI용) |
|
||||
| `POST /api/v1/roles/{id}/permissions/toggle` | 개별 권한 토글 |
|
||||
| `POST /api/v1/roles/{id}/permissions/allow-all` | 전체 허용 |
|
||||
| `POST /api/v1/roles/{id}/permissions/deny-all` | 전체 거부 |
|
||||
**actions가 null인 경우**: 해당 메뉴에 해당 권한이 정의되지 않음
|
||||
|
||||
### 3.3 권한 매트릭스 API 목록
|
||||
|
||||
| 엔드포인트 | 메서드 | 설명 |
|
||||
|-----------|--------|------|
|
||||
| `/api/v1/permissions/users/{user_id}/menu-matrix` | GET | 사용자별 권한 매트릭스 |
|
||||
| `/api/v1/permissions/roles/{role_id}/menu-matrix` | GET | 역할별 권한 매트릭스 |
|
||||
| `/api/v1/permissions/departments/{dept_id}/menu-matrix` | GET | 부서별 권한 매트릭스 |
|
||||
|
||||
### 3.4 역할 권한 관리 API
|
||||
|
||||
| 엔드포인트 | 메서드 | 설명 |
|
||||
|-----------|--------|------|
|
||||
| `/api/v1/role-permissions/menus` | GET | 권한 설정용 메뉴 트리 |
|
||||
| `/api/v1/roles/{id}/permissions` | GET | 역할 권한 목록 |
|
||||
| `/api/v1/roles/{id}/permissions` | POST | 역할 권한 부여 |
|
||||
| `/api/v1/roles/{id}/permissions` | DELETE | 역할 권한 회수 |
|
||||
| `/api/v1/roles/{id}/permissions/sync` | PUT | 역할 권한 동기화 |
|
||||
| `/api/v1/roles/{id}/permissions/matrix` | GET | 역할 권한 매트릭스 (설정 UI용) |
|
||||
| `/api/v1/roles/{id}/permissions/toggle` | POST | 개별 권한 토글 |
|
||||
| `/api/v1/roles/{id}/permissions/allow-all` | POST | 전체 허용 |
|
||||
| `/api/v1/roles/{id}/permissions/deny-all` | POST | 전체 거부 |
|
||||
| `/api/v1/roles/{id}/permissions/reset` | POST | 기본값 초기화 (view만 허용) |
|
||||
|
||||
---
|
||||
|
||||
@@ -124,13 +184,15 @@ HTTP 메서드에 따라 액션 자동 매핑:
|
||||
- 로그인 시 `menus`, `roles` 데이터 저장 (localStorage)
|
||||
- 사이드바 메뉴 표시 (백엔드에서 필터링된 메뉴)
|
||||
- 메뉴 폴링 (30초 주기)
|
||||
- 역할별 권한 설정 UI (`/settings/permissions/[id]`)
|
||||
|
||||
### 5.2 미구현 사항
|
||||
|
||||
- 권한 매트릭스 API 호출
|
||||
- 권한 데이터 저장
|
||||
- 권한 데이터 저장 (permissionStore)
|
||||
- `usePermission` 훅
|
||||
- 페이지/버튼별 권한 체크
|
||||
- 환경 변수 플래그
|
||||
|
||||
---
|
||||
|
||||
@@ -143,7 +205,7 @@ HTTP 메서드에 따라 액션 자동 매핑:
|
||||
↓
|
||||
/api/v1/permissions/users/{userId}/menu-matrix 호출
|
||||
↓
|
||||
권한 매트릭스 저장 (Zustand/localStorage)
|
||||
권한 매트릭스 저장 (Zustand permissionStore)
|
||||
↓
|
||||
usePermission 훅으로 권한 체크
|
||||
↓
|
||||
@@ -152,12 +214,13 @@ usePermission 훅으로 권한 체크
|
||||
|
||||
**usePermission 훅 예시**:
|
||||
```typescript
|
||||
// 사용법
|
||||
const { canView, canCreate, canUpdate, canDelete } = usePermission('판매관리');
|
||||
// 사용법 (메뉴명 또는 URL로 조회)
|
||||
const { canView, canCreate, canUpdate, canDelete, canApprove } = usePermission('/sales/orders');
|
||||
|
||||
// 적용
|
||||
{canCreate && <Button>등록</Button>}
|
||||
{canDelete && <Button>삭제</Button>}
|
||||
{canApprove && <Button>승인</Button>}
|
||||
```
|
||||
|
||||
**환경 변수 플래그**:
|
||||
@@ -209,6 +272,7 @@ Route::post('/orders', [OrderController::class, 'store'])
|
||||
| `src/components/settings/PermissionManagement/` | 권한 관리 컴포넌트 |
|
||||
| `src/layouts/AuthenticatedLayout.tsx` | 메뉴 표시 레이아웃 |
|
||||
| `src/middleware.ts` | 인증 체크 (권한 체크 없음) |
|
||||
| `src/store/menuStore.ts` | 메뉴 상태 관리 |
|
||||
|
||||
### 백엔드 (sam-api)
|
||||
|
||||
@@ -218,7 +282,9 @@ Route::post('/orders', [OrderController::class, 'store'])
|
||||
| `app/Http/Controllers/Api/V1/RolePermissionController.php` | 역할 권한 API |
|
||||
| `app/Http/Middleware/CheckPermission.php` | 권한 체크 미들웨어 |
|
||||
| `app/Http/Middleware/PermMapper.php` | HTTP → 액션 매핑 |
|
||||
| `app/Services/PermissionService.php` | 권한 매트릭스 서비스 |
|
||||
| `app/Services/Authz/AccessService.php` | 권한 판정 서비스 |
|
||||
| `app/Services/Authz/RolePermissionService.php` | 역할 권한 서비스 |
|
||||
|
||||
---
|
||||
|
||||
|
||||
153
claudedocs/[IMPL-2026-01-12] permission-frontend-checklist.md
Normal file
153
claudedocs/[IMPL-2026-01-12] permission-frontend-checklist.md
Normal file
@@ -0,0 +1,153 @@
|
||||
# 프론트엔드 권한 시스템 구현 체크리스트
|
||||
|
||||
> 작성일: 2026-01-12
|
||||
> 참고 문서: [ANALYSIS-2026-01-07] permission-system-status.md
|
||||
|
||||
---
|
||||
|
||||
## 구현 목표
|
||||
|
||||
로그인한 사용자의 권한에 따라 UI 요소(버튼, 메뉴 등)를 동적으로 표시/숨김 처리
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: 기반 구조 구축
|
||||
|
||||
### 1.1 타입 정의
|
||||
- [ ] `src/types/permission.ts` 생성
|
||||
- [ ] `PermissionAction` 타입 (view, create, update, delete, approve)
|
||||
- [ ] `PermissionState` 타입 (allow, deny, none)
|
||||
- [ ] `MenuPermission` 인터페이스 (API 응답 구조)
|
||||
- [ ] `PermissionMatrix` 인터페이스 (트리 → 플랫 변환용)
|
||||
|
||||
### 1.2 환경 변수 설정
|
||||
- [ ] `.env.local`에 `NEXT_PUBLIC_ENABLE_AUTHORIZATION=false` 추가
|
||||
- [ ] `.env.example`에 동일 항목 추가 (문서화)
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: 상태 관리
|
||||
|
||||
### 2.1 Permission Store 생성
|
||||
- [ ] `src/store/permissionStore.ts` 생성
|
||||
- [ ] 상태 정의
|
||||
- [ ] `permissions`: URL 기반 권한 맵 (`Record<string, PermissionActions>`)
|
||||
- [ ] `isLoaded`: 권한 로딩 완료 여부
|
||||
- [ ] `isEnabled`: 환경 변수 기반 활성화 여부
|
||||
- [ ] 액션 정의
|
||||
- [ ] `setPermissions(tree)`: API 응답 트리를 플랫 맵으로 변환 저장
|
||||
- [ ] `clearPermissions()`: 로그아웃 시 초기화
|
||||
- [ ] `hasPermission(url, action)`: 권한 체크 함수
|
||||
- [ ] persist 미들웨어 적용 (localStorage)
|
||||
|
||||
### 2.2 유틸리티 함수
|
||||
- [ ] `src/lib/permission-utils.ts` 생성
|
||||
- [ ] `flattenPermissionTree(tree)`: 트리 구조를 URL 기반 플랫 맵으로 변환
|
||||
- [ ] `normalizeUrl(url)`: URL 정규화 (locale 제거 등)
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: API 연동
|
||||
|
||||
### 3.1 Server Action 생성
|
||||
- [ ] `src/lib/api/permissions/actions.ts` 생성
|
||||
- [ ] `getUserPermissions(userId)`: 권한 매트릭스 API 호출
|
||||
|
||||
### 3.2 로그인 플로우 연동
|
||||
- [ ] 로그인 성공 후 권한 API 호출 로직 추가
|
||||
- [ ] `AuthenticatedLayout.tsx` 또는 로그인 처리 부분에서 호출
|
||||
- [ ] 권한 로딩 중 상태 처리 (로딩 UI 또는 스켈레톤)
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: usePermission 훅 구현
|
||||
|
||||
### 4.1 훅 생성
|
||||
- [ ] `src/hooks/usePermission.ts` 생성
|
||||
- [ ] 입력: 메뉴 URL 또는 메뉴명
|
||||
- [ ] 출력:
|
||||
```typescript
|
||||
{
|
||||
canView: boolean;
|
||||
canCreate: boolean;
|
||||
canUpdate: boolean;
|
||||
canDelete: boolean;
|
||||
canApprove: boolean;
|
||||
isLoading: boolean;
|
||||
}
|
||||
```
|
||||
- [ ] 환경 변수 비활성화 시 모두 `true` 반환
|
||||
|
||||
### 4.2 편의 컴포넌트 (선택사항)
|
||||
- [ ] `src/components/common/PermissionGuard.tsx` 생성
|
||||
```typescript
|
||||
<PermissionGuard menu="/sales/orders" action="create">
|
||||
<Button>등록</Button>
|
||||
</PermissionGuard>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: 적용 및 테스트
|
||||
|
||||
### 5.1 샘플 페이지 적용
|
||||
- [ ] 테스트용 페이지 1개 선정 (예: 판매관리)
|
||||
- [ ] 등록/수정/삭제 버튼에 권한 체크 적용
|
||||
- [ ] 동작 확인
|
||||
|
||||
### 5.2 전체 적용 (점진적)
|
||||
- [ ] 주요 페이지 목록 작성
|
||||
- [ ] 각 페이지별 권한 적용 진행
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: 예외 처리 및 UX
|
||||
|
||||
### 6.1 에러 처리
|
||||
- [ ] 권한 API 실패 시 fallback 처리 (모두 허용 or 모두 거부)
|
||||
- [ ] 네트워크 오류 시 재시도 로직
|
||||
|
||||
### 6.2 UX 개선
|
||||
- [ ] 권한 없는 버튼: 숨김 vs 비활성화(disabled) 정책 결정
|
||||
- [ ] 권한 없는 페이지 접근 시 처리 (리다이렉트 or 안내 메시지)
|
||||
|
||||
---
|
||||
|
||||
## 파일 생성 목록 요약
|
||||
|
||||
| 파일 경로 | 설명 |
|
||||
|----------|------|
|
||||
| `src/types/permission.ts` | 권한 관련 타입 정의 |
|
||||
| `src/store/permissionStore.ts` | 권한 상태 관리 (Zustand) |
|
||||
| `src/lib/permission-utils.ts` | 권한 유틸리티 함수 |
|
||||
| `src/lib/api/permissions/actions.ts` | 권한 API Server Action |
|
||||
| `src/hooks/usePermission.ts` | 권한 체크 훅 |
|
||||
| `src/components/common/PermissionGuard.tsx` | 권한 가드 컴포넌트 (선택) |
|
||||
|
||||
---
|
||||
|
||||
## 의존성
|
||||
|
||||
- 추가 패키지 설치 불필요 (기존 Zustand 활용)
|
||||
|
||||
---
|
||||
|
||||
## 주의사항
|
||||
|
||||
1. **환경 변수 기본값**: 개발 중에는 `NEXT_PUBLIC_ENABLE_AUTHORIZATION=false`로 비활성화
|
||||
2. **플랫 맵 변환**: API 응답이 트리 구조이므로 URL 기반 플랫 맵으로 변환 필요
|
||||
3. **URL 정규화**: locale prefix (`/ko`, `/en`) 제거하여 비교
|
||||
4. **로그아웃 시 초기화**: permissionStore 클리어 필수
|
||||
|
||||
---
|
||||
|
||||
## 예상 작업 순서
|
||||
|
||||
```
|
||||
Phase 1 (타입/환경변수) → Phase 2 (스토어) → Phase 3 (API 연동)
|
||||
→ Phase 4 (훅) → Phase 5 (적용) → Phase 6 (예외처리)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
*체크리스트 완료 후 이 문서를 archive로 이동*
|
||||
133
claudedocs/[IMPL-2026-01-12] quote-v2-test-pages-checklist.md
Normal file
133
claudedocs/[IMPL-2026-01-12] quote-v2-test-pages-checklist.md
Normal file
@@ -0,0 +1,133 @@
|
||||
# [IMPL-2026-01-12] 견적 V2 테스트 페이지 구현
|
||||
|
||||
## 개요
|
||||
- **목적**: 견적 등록/상세/수정 페이지의 새로운 UI (자동 견적 산출 V2) 테스트
|
||||
- **원칙**: 기존 견적관리 페이지는 절대 수정하지 않음 (API 연결됨)
|
||||
- **범위**: 테스트 페이지 3개 + 새 컴포넌트 생성
|
||||
|
||||
---
|
||||
|
||||
## 스크린샷 기반 UI 구성
|
||||
|
||||
### 레이아웃 구조
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ [발주 개소 목록 (3)] │ [1층 / FSS-01 상세정보] │
|
||||
│ ┌──────────────────────┐ │ 제품명: KSS01 │
|
||||
│ │ 층 │ 부호 │사이즈│제품│수량│ 오픈사이즈: 5000 × 3000 │
|
||||
│ │ 1층│FSS-01│5000×3000│KSS01│1│ 제작사이즈/중량/면적/수량 │
|
||||
│ │ 3층│FST-30│7500×3300│KSS02│1│ ───────────────────── │
|
||||
│ │ 5층│FSS-50│6000×2800│KSS01│2│ 필수설정: 가이드레일/전원/제어기│
|
||||
│ └──────────────────────┘ │ ───────────────────── │
|
||||
│ [품목 추가 폼] │ [탭: 본체│철골품-가이드레일│...]│
|
||||
│ 층|부호|가로|세로|제품명|수량 │ [품목 테이블] │
|
||||
│ 가이드레일|전원|제어기 [+][↑] │ │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ 💰 견적 금액 요약 │
|
||||
│ ┌─────────────────┐ ┌──────────────────────────────┐ │
|
||||
│ │ 개소별 합계 │ │ 상세별 합계 (선택 개소) │ │
|
||||
│ │ 1층/FSS-01 1,645,200│ │ 본체(스크린/슬랫) 1,061,676 │ │
|
||||
│ │ 3층/FST-30 2,589,198│ │ 철골품-가이드레일 116,556 │ │
|
||||
│ │ 5층/FSS-50 3,442,428│ │ ... │ │
|
||||
│ └─────────────────┘ └──────────────────────────────┘ │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ 총 개소 수: 3 │ 예상 견적금액: 11,119,254 │ 견적상태: 작성중│
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ 예상 전체 견적금액 [견적서산출] [임시저장] [최종저장] │
|
||||
│ 11,119,254원 │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 기능 요약
|
||||
| 영역 | 기능 |
|
||||
|------|------|
|
||||
| 발주 개소 목록 | 테이블로 개소 표시, 클릭 시 우측 상세 변경 |
|
||||
| 품목 추가 폼 | 층/부호/사이즈/제품/수량 + 설정 입력 후 [+] 추가 |
|
||||
| 엑셀 업로드 | [↑] 버튼으로 엑셀 일괄 업로드 |
|
||||
| 상세 정보 | 선택 개소의 제품정보, 필수설정, 품목탭 |
|
||||
| 견적 금액 요약 | 개소별 합계 + 상세별 합계 |
|
||||
| 푸터 | 총 개소 수, 예상 견적금액, 견적 상태 |
|
||||
| 버튼 | 견적서 산출, 임시저장, 최종저장 (미리보기 제외) |
|
||||
|
||||
---
|
||||
|
||||
## 파일 구조
|
||||
|
||||
### 테스트 페이지 (새로 생성)
|
||||
```
|
||||
src/app/[locale]/(protected)/sales/quote-management/
|
||||
├── test-new/page.tsx ← 테스트 등록 페이지
|
||||
├── test/[id]/page.tsx ← 테스트 상세 페이지
|
||||
└── test/[id]/edit/page.tsx ← 테스트 수정 페이지
|
||||
```
|
||||
|
||||
### 컴포넌트 (새로 생성)
|
||||
```
|
||||
src/components/quotes/
|
||||
├── QuoteRegistrationV2.tsx ← 메인 컴포넌트 (새 UI)
|
||||
├── LocationListPanel.tsx ← 왼쪽: 발주 개소 목록 + 추가 폼
|
||||
├── LocationDetailPanel.tsx ← 오른쪽: 선택 개소 상세
|
||||
├── QuoteSummaryPanel.tsx ← 견적 금액 요약
|
||||
├── QuoteFooterBar.tsx ← 하단 푸터 바
|
||||
└── ExcelUploadButton.tsx ← 엑셀 업로드/다운로드
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 작업 체크리스트
|
||||
|
||||
### Phase 1: 기본 구조 설정
|
||||
- [ ] 테스트 등록 페이지 생성 (test-new/page.tsx)
|
||||
- [ ] 테스트 상세 페이지 생성 (test/[id]/page.tsx)
|
||||
- [ ] 테스트 수정 페이지 생성 (test/[id]/edit/page.tsx)
|
||||
- [ ] /dev/test-urls에 테스트 URL 추가
|
||||
|
||||
### Phase 2: 핵심 컴포넌트 구현
|
||||
- [ ] QuoteRegistrationV2.tsx 메인 컴포넌트 생성
|
||||
- [ ] LocationListPanel.tsx 발주 개소 목록 구현
|
||||
- [ ] LocationDetailPanel.tsx 상세 정보 구현
|
||||
- [ ] QuoteSummaryPanel.tsx 금액 요약 구현
|
||||
- [ ] QuoteFooterBar.tsx 푸터 바 구현
|
||||
|
||||
### Phase 3: 상세 기능 구현
|
||||
- [ ] 개소 선택 시 우측 상세 변경 기능
|
||||
- [ ] 품목 추가 폼 기능
|
||||
- [ ] 탭 전환 기능 (본체, 철골품 등)
|
||||
- [ ] 품목 테이블 표시
|
||||
|
||||
### Phase 4: 엑셀 기능
|
||||
- [ ] ExcelUploadButton.tsx 컴포넌트 생성
|
||||
- [ ] 엑셀 양식 다운로드 기능
|
||||
- [ ] 엑셀 업로드 및 파싱 기능
|
||||
|
||||
### Phase 5: 버튼 및 저장 기능
|
||||
- [ ] 견적서 산출 버튼 기능
|
||||
- [ ] 임시저장 버튼 기능
|
||||
- [ ] 최종저장 버튼 기능
|
||||
|
||||
---
|
||||
|
||||
## 참고 사항
|
||||
|
||||
### 기존 파일 (수정 금지)
|
||||
- `src/app/[locale]/(protected)/sales/quote-management/page.tsx` (목록)
|
||||
- `src/app/[locale]/(protected)/sales/quote-management/new/page.tsx` (등록)
|
||||
- `src/app/[locale]/(protected)/sales/quote-management/[id]/page.tsx` (상세)
|
||||
- `src/app/[locale]/(protected)/sales/quote-management/[id]/edit/page.tsx` (수정)
|
||||
- `src/components/quotes/QuoteRegistration.tsx` (기존 컴포넌트)
|
||||
|
||||
### 재사용 가능 파일
|
||||
- `src/components/quotes/actions.ts` (API 호출)
|
||||
- `src/components/quotes/QuoteDocument.tsx` (견적서 문서)
|
||||
- `src/components/quotes/types.ts` (타입 정의)
|
||||
|
||||
### 디자인 원칙
|
||||
- 내용/기능: 스크린샷 충실히 구현
|
||||
- 스타일/레이아웃: 기존 프로젝트 패턴 따르기
|
||||
- 색상: 주황색 헤더, 노란색 배경 등 스크린샷 참고
|
||||
|
||||
---
|
||||
|
||||
## 진행 상태
|
||||
- 시작일: 2026-01-12
|
||||
- 현재 상태: 계획 수립 완료
|
||||
@@ -58,10 +58,23 @@ http://localhost:3000/ko/hr/attendance # 🧪 모바일 출퇴근 (테스트)
|
||||
| 견적관리 | `/ko/sales/quote-management` | ✅ |
|
||||
| 단가관리 | `/ko/sales/pricing-management` | ✅ |
|
||||
|
||||
### 견적 V2 테스트 (새 UI)
|
||||
|
||||
| 페이지 | URL | 상태 |
|
||||
|--------|-----|------|
|
||||
| **견적 등록 (V2)** | `/ko/sales/quote-management/test-new` | 🧪 테스트 |
|
||||
| **견적 상세 (V2)** | `/ko/sales/quote-management/test/1` | 🧪 테스트 |
|
||||
| **견적 수정 (V2)** | `/ko/sales/quote-management/test/1/edit` | 🧪 테스트 |
|
||||
|
||||
```
|
||||
http://localhost:3000/ko/sales/client-management-sales-admin
|
||||
http://localhost:3000/ko/sales/quote-management
|
||||
http://localhost:3000/ko/sales/pricing-management
|
||||
|
||||
# 견적 V2 테스트 (새 UI)
|
||||
http://localhost:3000/ko/sales/quote-management/test-new # 🧪 견적 등록 V2
|
||||
http://localhost:3000/ko/sales/quote-management/test/1 # 🧪 견적 상세 V2
|
||||
http://localhost:3000/ko/sales/quote-management/test/1/edit # 🧪 견적 수정 V2
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# Juil Enterprise Test URLs
|
||||
Last Updated: 2026-01-05
|
||||
Last Updated: 2026-01-12
|
||||
|
||||
### 대시보드
|
||||
| 페이지 | URL | 상태 |
|
||||
@@ -7,10 +7,11 @@ Last Updated: 2026-01-05
|
||||
| **메인 대시보드** | `/ko/construction/dashboard` | ✅ 완료 |
|
||||
|
||||
## 프로젝트 관리 (Project)
|
||||
### 메인
|
||||
|
||||
### 프로젝트관리 (Management)
|
||||
| 페이지 | URL | 상태 |
|
||||
|---|---|---|
|
||||
| **프로젝트 관리 메인** | `/ko/construction/project` | 🚧 구조잡기 |
|
||||
| **프로젝트 관리** | `/ko/construction/project/management` | ✅ 완료 |
|
||||
|
||||
### 입찰관리 (Bidding)
|
||||
| 페이지 | URL | 상태 |
|
||||
@@ -42,25 +43,4 @@ Last Updated: 2026-01-05
|
||||
| **노임관리** | `/ko/construction/order/base-info/labor` | 🆕 NEW |
|
||||
|
||||
## 공사 관리 (Construction)
|
||||
### 인수인계 / 실측 / 발주 / 시공
|
||||
| 페이지 | URL | 상태 |
|
||||
|---|---|---|
|
||||
| **공사 관리 메인** | `/ko/construction/construction` | 🚧 구조잡기 |
|
||||
|
||||
## 현장 작업 (Field)
|
||||
### 할당 / 인력 / 근태 / 보고
|
||||
| 페이지 | URL | 상태 |
|
||||
|---|---|---|
|
||||
| **현장 작업 메인** | `/ko/construction/field` | 🚧 구조잡기 |
|
||||
|
||||
## 기성/정산 (Finance)
|
||||
### 기성 / 변경계약 / 정산
|
||||
| 페이지 | URL | 상태 |
|
||||
|---|---|---|
|
||||
| **재무 관리 메인** | `/ko/construction/finance` | 🚧 구조잡기 |
|
||||
|
||||
## 시스템 (System)
|
||||
### 공통
|
||||
| 페이지 | URL | 상태 |
|
||||
|---|---|---|
|
||||
| **개발용 메뉴 목록** | `/ko/dev/juil-test-urls` | ✅ 완료 |
|
||||
|
||||
104
package-lock.json
generated
104
package-lock.json
generated
@@ -52,6 +52,7 @@
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"vaul": "^1.1.2",
|
||||
"xlsx": "^0.18.5",
|
||||
"zod": "^4.1.12",
|
||||
"zustand": "^5.0.9"
|
||||
},
|
||||
@@ -4996,6 +4997,15 @@
|
||||
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/adler-32": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz",
|
||||
"integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/ajv": {
|
||||
"version": "6.12.6",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
|
||||
@@ -5381,6 +5391,19 @@
|
||||
],
|
||||
"license": "CC-BY-4.0"
|
||||
},
|
||||
"node_modules/cfb": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz",
|
||||
"integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"adler-32": "~1.3.0",
|
||||
"crc-32": "~1.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/chalk": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
||||
@@ -5441,6 +5464,15 @@
|
||||
"react-dom": "^18 || ^19 || ^19.0.0-rc"
|
||||
}
|
||||
},
|
||||
"node_modules/codepage": {
|
||||
"version": "1.15.0",
|
||||
"resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz",
|
||||
"integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/color-convert": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||
@@ -5468,6 +5500,18 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/crc-32": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
|
||||
"integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"crc32": "bin/crc32.njs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/crelt": {
|
||||
"version": "1.0.6",
|
||||
"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz",
|
||||
@@ -6603,6 +6647,15 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/frac": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz",
|
||||
"integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
@@ -9349,6 +9402,18 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ssf": {
|
||||
"version": "0.11.2",
|
||||
"resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz",
|
||||
"integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"frac": "~1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/stable-hash": {
|
||||
"version": "0.0.5",
|
||||
"resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz",
|
||||
@@ -10092,6 +10157,24 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/wmf": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz",
|
||||
"integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/word": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz",
|
||||
"integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/word-wrap": {
|
||||
"version": "1.2.5",
|
||||
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
|
||||
@@ -10102,6 +10185,27 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/xlsx": {
|
||||
"version": "0.18.5",
|
||||
"resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz",
|
||||
"integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"adler-32": "~1.3.0",
|
||||
"cfb": "~1.2.1",
|
||||
"codepage": "~1.15.0",
|
||||
"crc-32": "~1.2.1",
|
||||
"ssf": "~0.11.2",
|
||||
"wmf": "~1.0.1",
|
||||
"word": "~0.3.0"
|
||||
},
|
||||
"bin": {
|
||||
"xlsx": "bin/xlsx.njs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/yocto-queue": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
||||
|
||||
@@ -57,6 +57,7 @@
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"vaul": "^1.1.2",
|
||||
"xlsx": "^0.18.5",
|
||||
"zod": "^4.1.12",
|
||||
"zustand": "^5.0.9"
|
||||
},
|
||||
|
||||
@@ -19,6 +19,8 @@ function getEstimateDetail(id: string): EstimateDetail {
|
||||
projectName: '현장명',
|
||||
estimatorId: 'hong',
|
||||
estimatorName: '이름',
|
||||
estimateCompanyManager: '홍길동',
|
||||
estimateCompanyManagerContact: '01012341234',
|
||||
itemCount: 21,
|
||||
estimateAmount: 1420000,
|
||||
completedDate: null,
|
||||
|
||||
@@ -19,6 +19,8 @@ function getEstimateDetail(id: string): EstimateDetail {
|
||||
projectName: '현장명',
|
||||
estimatorId: 'hong',
|
||||
estimatorName: '이름',
|
||||
estimateCompanyManager: '홍길동',
|
||||
estimateCompanyManagerContact: '01012341234',
|
||||
itemCount: 21,
|
||||
estimateAmount: 1420000,
|
||||
completedDate: null,
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import ContractDetailForm from '@/components/business/construction/contract/ContractDetailForm';
|
||||
import { getContractDetail } from '@/components/business/construction/contract';
|
||||
import type { ContractDetail } from '@/components/business/construction/contract/types';
|
||||
|
||||
export default function ContractCreatePage() {
|
||||
const searchParams = useSearchParams();
|
||||
const baseContractId = searchParams.get('baseContractId');
|
||||
|
||||
const [baseData, setBaseData] = useState<ContractDetail | undefined>(undefined);
|
||||
const [isLoading, setIsLoading] = useState(!!baseContractId);
|
||||
|
||||
useEffect(() => {
|
||||
if (baseContractId) {
|
||||
// 변경 계약서 생성: 기존 계약 데이터 복사
|
||||
getContractDetail(baseContractId)
|
||||
.then(result => {
|
||||
if (result.success && result.data) {
|
||||
setBaseData(result.data);
|
||||
}
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
}
|
||||
}, [baseContractId]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-muted-foreground">로딩 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ContractDetailForm
|
||||
mode="create"
|
||||
contractId=""
|
||||
initialData={baseData}
|
||||
isChangeContract={!!baseContractId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { ProjectListClient } from '@/components/business/construction/management';
|
||||
|
||||
export default function ProjectManagementPage() {
|
||||
return <ProjectListClient />;
|
||||
}
|
||||
@@ -1,13 +1,14 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { ChevronDown, ChevronRight, Check, Search, X } from 'lucide-react';
|
||||
import { ChevronDown, ChevronRight, Check } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { ChecklistCategory, ChecklistSubItem } from '../types';
|
||||
|
||||
interface Day1ChecklistPanelProps {
|
||||
categories: ChecklistCategory[];
|
||||
selectedSubItemId: string | null;
|
||||
searchTerm: string;
|
||||
onSubItemSelect: (categoryId: string, subItemId: string) => void;
|
||||
onSubItemToggle: (categoryId: string, subItemId: string, isCompleted: boolean) => void;
|
||||
}
|
||||
@@ -15,13 +16,13 @@ interface Day1ChecklistPanelProps {
|
||||
export function Day1ChecklistPanel({
|
||||
categories,
|
||||
selectedSubItemId,
|
||||
searchTerm,
|
||||
onSubItemSelect,
|
||||
onSubItemToggle,
|
||||
}: Day1ChecklistPanelProps) {
|
||||
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(
|
||||
new Set(categories.map(c => c.id)) // 기본적으로 모두 펼침
|
||||
);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
|
||||
// 검색 필터링된 카테고리
|
||||
const filteredCategories = useMemo(() => {
|
||||
@@ -74,10 +75,6 @@ export function Day1ChecklistPanel({
|
||||
return { completed, total: originalCategory.subItems.length };
|
||||
};
|
||||
|
||||
const clearSearch = () => {
|
||||
setSearchTerm('');
|
||||
};
|
||||
|
||||
// 검색 결과 하이라이트
|
||||
const highlightText = (text: string, term: string) => {
|
||||
if (!term.trim()) return text;
|
||||
@@ -96,29 +93,9 @@ export function Day1ChecklistPanel({
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg border border-gray-200 overflow-hidden h-full flex flex-col">
|
||||
{/* 헤더 + 검색 */}
|
||||
{/* 헤더 */}
|
||||
<div className="bg-gray-100 px-2 sm:px-4 py-2 sm:py-3 border-b border-gray-200">
|
||||
<h3 className="font-semibold text-gray-900 text-sm sm:text-base mb-2">점검표 항목</h3>
|
||||
{/* 검색 입력 */}
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2 sm:left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="항목 검색..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="w-full pl-8 sm:pl-9 pr-8 py-1.5 sm:py-2 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
/>
|
||||
{searchTerm && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={clearSearch}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 hover:bg-gray-200 rounded-full transition-colors"
|
||||
>
|
||||
<X className="h-4 w-4 text-gray-400" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<h3 className="font-semibold text-gray-900 text-sm sm:text-base">점검표 항목</h3>
|
||||
{/* 검색 결과 카운트 */}
|
||||
{searchTerm && (
|
||||
<div className="mt-1.5 sm:mt-2 text-xs text-gray-500">
|
||||
|
||||
@@ -270,6 +270,16 @@ export default function QualityInspectionPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 공통 필터 (1일차/2일차 모두 사용) */}
|
||||
<Filters
|
||||
selectedYear={selectedYear}
|
||||
selectedQuarter={selectedQuarter}
|
||||
searchTerm={searchTerm}
|
||||
onYearChange={handleYearChange}
|
||||
onQuarterChange={handleQuarterChange}
|
||||
onSearchChange={handleSearchChange}
|
||||
/>
|
||||
|
||||
{activeDay === 1 ? (
|
||||
// ===== 1일차: 기준/매뉴얼 심사 =====
|
||||
<div className="flex-1 grid grid-cols-12 gap-3 sm:gap-4 lg:min-h-0">
|
||||
@@ -284,6 +294,7 @@ export default function QualityInspectionPage() {
|
||||
<Day1ChecklistPanel
|
||||
categories={filteredDay1Categories}
|
||||
selectedSubItemId={selectedSubItemId}
|
||||
searchTerm={searchTerm}
|
||||
onSubItemSelect={handleSubItemSelect}
|
||||
onSubItemToggle={handleSubItemToggle}
|
||||
/>
|
||||
@@ -315,17 +326,7 @@ export default function QualityInspectionPage() {
|
||||
</div>
|
||||
) : (
|
||||
// ===== 2일차: 로트추적 심사 =====
|
||||
<>
|
||||
<Filters
|
||||
selectedYear={selectedYear}
|
||||
selectedQuarter={selectedQuarter}
|
||||
searchTerm={searchTerm}
|
||||
onYearChange={handleYearChange}
|
||||
onQuarterChange={handleQuarterChange}
|
||||
onSearchChange={handleSearchChange}
|
||||
/>
|
||||
|
||||
<div className="flex-1 grid grid-cols-12 gap-3 sm:gap-4 lg:gap-6 lg:min-h-0">
|
||||
<div className="flex-1 grid grid-cols-12 gap-3 sm:gap-4 lg:gap-6 lg:min-h-0">
|
||||
<div className="col-span-12 lg:col-span-3 min-h-[250px] sm:min-h-[300px] lg:min-h-0 lg:h-full overflow-auto lg:overflow-hidden">
|
||||
<ReportList
|
||||
reports={filteredReports}
|
||||
@@ -352,7 +353,6 @@ export default function QualityInspectionPage() {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 설정 패널 */}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* 견적 등록 테스트 페이지 (V2 UI)
|
||||
*
|
||||
* 새로운 자동 견적 산출 UI 테스트용
|
||||
* 기존 견적 등록 페이지는 수정하지 않음
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { QuoteRegistrationV2, QuoteFormDataV2 } from "@/components/quotes/QuoteRegistrationV2";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export default function QuoteTestNewPage() {
|
||||
const router = useRouter();
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
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" ? "임시" : "최종"} 저장 완료`);
|
||||
|
||||
// 저장 후 상세 페이지로 이동 (테스트용으로 ID=1 사용)
|
||||
if (saveType === "final") {
|
||||
router.push("/sales/quote-management/test/1");
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error("저장 중 오류가 발생했습니다.");
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<QuoteRegistrationV2
|
||||
mode="create"
|
||||
onBack={handleBack}
|
||||
onSave={handleSave}
|
||||
isLoading={isSaving}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* 견적 수정 테스트 페이지 (V2 UI)
|
||||
*
|
||||
* 새로운 자동 견적 산출 UI 테스트용
|
||||
* 기존 견적 수정 페이지는 수정하지 않음
|
||||
*/
|
||||
|
||||
"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() {
|
||||
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="견적 정보를 불러오는 중..." />;
|
||||
}
|
||||
|
||||
return (
|
||||
<QuoteRegistrationV2
|
||||
mode="edit"
|
||||
onBack={handleBack}
|
||||
onSave={handleSave}
|
||||
initialData={quote}
|
||||
isLoading={isSaving}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* 견적 상세 테스트 페이지 (V2 UI)
|
||||
*
|
||||
* 새로운 자동 견적 산출 UI 테스트용
|
||||
* 기존 견적 상세 페이지는 수정하지 않음
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import { useRouter, useParams } from "next/navigation";
|
||||
import { useState, useEffect } from "react";
|
||||
import { QuoteRegistrationV2, QuoteFormDataV2, LocationItem } 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 QuoteTestDetailPage() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const quoteId = params.id as string;
|
||||
|
||||
const [quote, setQuote] = useState<QuoteFormDataV2 | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
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");
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return <ContentLoadingSpinner text="견적 정보를 불러오는 중..." />;
|
||||
}
|
||||
|
||||
return (
|
||||
<QuoteRegistrationV2
|
||||
mode="view"
|
||||
onBack={handleBack}
|
||||
initialData={quote}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -36,7 +36,7 @@ import {
|
||||
getEmptyContractFormData,
|
||||
contractDetailToFormData,
|
||||
} from './types';
|
||||
import { updateContract, deleteContract } from './actions';
|
||||
import { updateContract, deleteContract, createContract } from './actions';
|
||||
import { downloadFileById } from '@/lib/utils/fileDownload';
|
||||
import { ContractDocumentModal } from './modals/ContractDocumentModal';
|
||||
import {
|
||||
@@ -59,19 +59,22 @@ function formatFileSize(bytes: number): string {
|
||||
}
|
||||
|
||||
interface ContractDetailFormProps {
|
||||
mode: 'view' | 'edit';
|
||||
mode: 'view' | 'edit' | 'create';
|
||||
contractId: string;
|
||||
initialData?: ContractDetail;
|
||||
isChangeContract?: boolean; // 변경 계약서 생성 여부
|
||||
}
|
||||
|
||||
export default function ContractDetailForm({
|
||||
mode,
|
||||
contractId,
|
||||
initialData,
|
||||
isChangeContract = false,
|
||||
}: ContractDetailFormProps) {
|
||||
const router = useRouter();
|
||||
const isViewMode = mode === 'view';
|
||||
const isEditMode = mode === 'edit';
|
||||
const isCreateMode = mode === 'create';
|
||||
|
||||
// 폼 데이터
|
||||
const [formData, setFormData] = useState<ContractFormData>(
|
||||
@@ -121,10 +124,19 @@ export default function ContractDetailForm({
|
||||
router.push(`/ko/construction/project/contract/${contractId}/edit`);
|
||||
}, [router, contractId]);
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
router.push(`/ko/construction/project/contract/${contractId}`);
|
||||
// 변경 계약서 생성 핸들러
|
||||
const handleCreateChangeContract = useCallback(() => {
|
||||
router.push(`/ko/construction/project/contract/create?baseContractId=${contractId}`);
|
||||
}, [router, contractId]);
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
if (isCreateMode) {
|
||||
router.push('/ko/construction/project/contract');
|
||||
} else {
|
||||
router.push(`/ko/construction/project/contract/${contractId}`);
|
||||
}
|
||||
}, [router, contractId, isCreateMode]);
|
||||
|
||||
// 폼 필드 변경
|
||||
const handleFieldChange = useCallback(
|
||||
(field: keyof ContractFormData, value: string | number) => {
|
||||
@@ -141,14 +153,28 @@ export default function ContractDetailForm({
|
||||
const handleConfirmSave = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const result = await updateContract(contractId, formData);
|
||||
if (result.success) {
|
||||
toast.success('수정이 완료되었습니다.');
|
||||
setShowSaveDialog(false);
|
||||
router.push(`/ko/construction/project/contract/${contractId}`);
|
||||
router.refresh();
|
||||
if (isCreateMode) {
|
||||
// 새 계약 생성 (변경 계약서 포함)
|
||||
const result = await createContract(formData);
|
||||
if (result.success && result.data) {
|
||||
toast.success(isChangeContract ? '변경 계약서가 생성되었습니다.' : '계약이 생성되었습니다.');
|
||||
setShowSaveDialog(false);
|
||||
router.push(`/ko/construction/project/contract/${result.data.id}`);
|
||||
router.refresh();
|
||||
} else {
|
||||
toast.error(result.error || '저장에 실패했습니다.');
|
||||
}
|
||||
} else {
|
||||
toast.error(result.error || '저장에 실패했습니다.');
|
||||
// 기존 계약 수정
|
||||
const result = await updateContract(contractId, formData);
|
||||
if (result.success) {
|
||||
toast.success('수정이 완료되었습니다.');
|
||||
setShowSaveDialog(false);
|
||||
router.push(`/ko/construction/project/contract/${contractId}`);
|
||||
router.refresh();
|
||||
} else {
|
||||
toast.error(result.error || '저장에 실패했습니다.');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
@@ -156,7 +182,7 @@ export default function ContractDetailForm({
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [router, contractId, formData]);
|
||||
}, [router, contractId, formData, isCreateMode, isChangeContract]);
|
||||
|
||||
// 삭제 핸들러
|
||||
const handleDelete = useCallback(() => {
|
||||
@@ -280,6 +306,9 @@ export default function ContractDetailForm({
|
||||
// 헤더 액션 버튼
|
||||
const headerActions = isViewMode ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" onClick={handleCreateChangeContract}>
|
||||
변경 계약서 생성
|
||||
</Button>
|
||||
<Button variant="outline" onClick={handleViewDocument}>
|
||||
<Eye className="h-4 w-4 mr-2" />
|
||||
계약서 보기
|
||||
@@ -289,6 +318,15 @@ export default function ContractDetailForm({
|
||||
</Button>
|
||||
<Button onClick={handleEdit}>수정</Button>
|
||||
</div>
|
||||
) : isCreateMode ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" onClick={handleCancel}>
|
||||
취소
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={isLoading}>
|
||||
저장
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" onClick={handleCancel}>
|
||||
@@ -303,10 +341,15 @@ export default function ContractDetailForm({
|
||||
</div>
|
||||
);
|
||||
|
||||
// 페이지 타이틀
|
||||
const pageTitle = isCreateMode
|
||||
? (isChangeContract ? '변경 계약서 생성' : '계약 등록')
|
||||
: '계약 상세';
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
<PageHeader
|
||||
title="계약 상세"
|
||||
title={pageTitle}
|
||||
description="계약 정보를 관리합니다"
|
||||
icon={FileText}
|
||||
onBack={handleBack}
|
||||
@@ -483,8 +526,8 @@ export default function ContractDetailForm({
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{/* 파일 선택 버튼 (수정 모드에서만) */}
|
||||
{isEditMode && (
|
||||
{/* 파일 선택 버튼 (수정/생성 모드에서만) */}
|
||||
{(isEditMode || isCreateMode) && (
|
||||
<Button variant="outline" onClick={handleContractFileSelect}>
|
||||
찾기
|
||||
</Button>
|
||||
@@ -498,7 +541,7 @@ export default function ContractDetailForm({
|
||||
<span className="text-sm font-medium">{formData.contractFile.name}</span>
|
||||
<span className="text-xs text-blue-600">(새 파일)</span>
|
||||
</div>
|
||||
{isEditMode && (
|
||||
{(isEditMode || isCreateMode) && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
@@ -526,7 +569,7 @@ export default function ContractDetailForm({
|
||||
<Download className="h-4 w-4 mr-1" />
|
||||
다운로드
|
||||
</Button>
|
||||
{isEditMode && (
|
||||
{(isEditMode || isCreateMode) && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
@@ -562,7 +605,7 @@ export default function ContractDetailForm({
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{/* 드래그 앤 드롭 영역 */}
|
||||
{isEditMode && (
|
||||
{(isEditMode || isCreateMode) && (
|
||||
<div
|
||||
className={`border-2 border-dashed rounded-lg p-8 text-center mb-4 transition-colors cursor-pointer ${
|
||||
isDragging ? 'border-primary bg-primary/5' : 'border-muted-foreground/25'
|
||||
@@ -605,7 +648,7 @@ export default function ContractDetailForm({
|
||||
<Download className="h-4 w-4 mr-1" />
|
||||
다운로드
|
||||
</Button>
|
||||
{isEditMode && (
|
||||
{(isEditMode || isCreateMode) && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
|
||||
@@ -514,4 +514,26 @@ export async function updateContract(
|
||||
console.error('updateContract error:', error);
|
||||
return { success: false, error: '계약 수정에 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// 계약 생성 (변경 계약서 생성 포함)
|
||||
export async function createContract(
|
||||
_data: ContractFormData
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
data?: { id: string };
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
|
||||
// TODO: 실제 API 연동 시 데이터 생성 로직
|
||||
// 새 계약 ID 생성 (목업)
|
||||
const newId = String(MOCK_CONTRACTS.length + 1);
|
||||
|
||||
return { success: true, data: { id: newId } };
|
||||
} catch (error) {
|
||||
console.error('createContract error:', error);
|
||||
return { success: false, error: '계약 생성에 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
@@ -88,6 +88,8 @@ export function EstimateDocumentModal({
|
||||
address: '주소',
|
||||
amount: formData.summaryItems.reduce((sum, item) => sum + item.totalCost, 0),
|
||||
date: formData.bidInfo.bidDate || '2025년 12월 12일',
|
||||
manager: formData.estimateCompanyManager || '',
|
||||
managerContact: formData.estimateCompanyManagerContact || '',
|
||||
contact: {
|
||||
hp: '010-3679-2188',
|
||||
tel: '(02) 849-5130',
|
||||
@@ -194,17 +196,22 @@ export function EstimateDocumentModal({
|
||||
<td className="border border-gray-400 px-3 py-2">{documentData.date}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="border border-gray-400 px-3 py-2 bg-gray-100 text-center">연락처</td>
|
||||
<td className="border border-gray-400 px-3 py-2 bg-gray-100 text-center">담당자</td>
|
||||
<td className="border border-gray-400 px-3 py-2"></td>
|
||||
<td className="border border-gray-400 px-3 py-2 bg-gray-100 text-center">연락처</td>
|
||||
<td className="border border-gray-400 px-3 py-2">
|
||||
<td className="border border-gray-400 px-3 py-2 bg-gray-100 text-center" rowSpan={2}>연락처</td>
|
||||
<td className="border border-gray-400 px-3 py-2" rowSpan={2}>
|
||||
<div className="space-y-0.5 text-xs">
|
||||
<div>담당자 : {documentData.manager}</div>
|
||||
<div>H . P : {documentData.contact.hp}</div>
|
||||
<div>T E L : {documentData.contact.tel}</div>
|
||||
<div>F A X : {documentData.contact.fax}</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="border border-gray-400 px-3 py-2 bg-gray-100 text-center">연락처</td>
|
||||
<td className="border border-gray-400 px-3 py-2"></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
|
||||
@@ -53,29 +53,46 @@ export function EstimateInfoSection({
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">견적 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium text-gray-700">견적번호</Label>
|
||||
<Input value={formData.estimateCode} disabled className="bg-gray-50" />
|
||||
<CardContent className="space-y-4">
|
||||
{/* 1행: 견적번호, 견적자 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium text-gray-700">견적번호</Label>
|
||||
<Input value={formData.estimateCode} disabled className="bg-gray-50" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium text-gray-700">견적자</Label>
|
||||
<Input value={formData.estimatorName} disabled className="bg-gray-50" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium text-gray-700">견적자</Label>
|
||||
<Input value={formData.estimatorName} disabled className="bg-gray-50" />
|
||||
{/* 2행: 견적 회사 담당자, 견적 회사 담당자 연락처 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium text-gray-700">견적 회사 담당자</Label>
|
||||
<Input value={formData.estimateCompanyManager} disabled className="bg-gray-50" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium text-gray-700">견적 회사 담당자 연락처</Label>
|
||||
<Input value={formData.estimateCompanyManagerContact} disabled className="bg-gray-50" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium text-gray-700">견적금액</Label>
|
||||
<Input
|
||||
value={formatAmount(formData.estimateAmount)}
|
||||
disabled
|
||||
className="bg-gray-50 text-right"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium text-gray-700">상태</Label>
|
||||
<div className="flex items-center h-10 px-3 border rounded-md bg-gray-50">
|
||||
<span className={STATUS_STYLES[formData.status]}>
|
||||
{STATUS_LABELS[formData.status]}
|
||||
</span>
|
||||
{/* 3행: 견적금액, 상태 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium text-gray-700">견적금액</Label>
|
||||
<Input
|
||||
value={formatAmount(formData.estimateAmount)}
|
||||
disabled
|
||||
className="bg-gray-50 text-right"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium text-gray-700">상태</Label>
|
||||
<div className="flex items-center h-10 px-3 border rounded-md bg-gray-50">
|
||||
<span className={STATUS_STYLES[formData.status]}>
|
||||
{STATUS_LABELS[formData.status]}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
@@ -78,9 +78,11 @@ export function PriceAdjustmentSection({
|
||||
>
|
||||
전체 적용
|
||||
</Button>
|
||||
{/* 초기화 버튼 주석처리
|
||||
<Button type="button" variant="outline" size="sm" onClick={onReset}>
|
||||
초기화
|
||||
</Button>
|
||||
*/}
|
||||
</div>
|
||||
)}
|
||||
</CardHeader>
|
||||
|
||||
@@ -184,6 +184,8 @@ export interface EstimateDetailFormData {
|
||||
estimateCode: string;
|
||||
estimatorId: string;
|
||||
estimatorName: string;
|
||||
estimateCompanyManager: string; // 견적 회사 담당자
|
||||
estimateCompanyManagerContact: string; // 견적 회사 담당자 연락처
|
||||
estimateAmount: number;
|
||||
status: EstimateStatus;
|
||||
|
||||
@@ -251,6 +253,8 @@ export function getEmptyEstimateDetailFormData(): EstimateDetailFormData {
|
||||
estimateCode: '',
|
||||
estimatorId: '',
|
||||
estimatorName: '',
|
||||
estimateCompanyManager: '',
|
||||
estimateCompanyManagerContact: '',
|
||||
estimateAmount: 0,
|
||||
status: 'pending',
|
||||
siteBriefing: {
|
||||
@@ -290,6 +294,8 @@ export function estimateDetailToFormData(detail: EstimateDetail): EstimateDetail
|
||||
estimateCode: detail.estimateCode,
|
||||
estimatorId: detail.estimatorId,
|
||||
estimatorName: detail.estimatorName,
|
||||
estimateCompanyManager: detail.estimateCompanyManager || '',
|
||||
estimateCompanyManagerContact: detail.estimateCompanyManagerContact || '',
|
||||
estimateAmount: detail.estimateAmount,
|
||||
status: detail.status,
|
||||
siteBriefing: detail.siteBriefing,
|
||||
@@ -315,6 +321,8 @@ export interface Estimate {
|
||||
projectName: string; // 현장명
|
||||
estimatorId: string; // 견적자 ID
|
||||
estimatorName: string; // 견적자명
|
||||
estimateCompanyManager: string; // 견적 회사 담당자
|
||||
estimateCompanyManagerContact: string; // 견적 회사 담당자 연락처
|
||||
|
||||
// 견적 정보
|
||||
itemCount: number; // 총 개소 (품목 수)
|
||||
|
||||
@@ -0,0 +1,366 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo, useRef, useEffect, useState } from 'react';
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { Project, ChartViewMode } from './types';
|
||||
import { GANTT_BAR_COLORS } from './types';
|
||||
|
||||
interface ProjectGanttChartProps {
|
||||
projects: Project[];
|
||||
viewMode: ChartViewMode;
|
||||
currentDate: Date;
|
||||
onProjectClick: (project: Project) => void;
|
||||
onDateChange: (date: Date) => void;
|
||||
}
|
||||
|
||||
export default function ProjectGanttChart({
|
||||
projects,
|
||||
viewMode,
|
||||
currentDate,
|
||||
onProjectClick,
|
||||
onDateChange,
|
||||
}: ProjectGanttChartProps) {
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
const [isScrolling, setIsScrolling] = useState(false);
|
||||
|
||||
// 현재 날짜 기준으로 표시할 기간 계산
|
||||
const { columns, startDate, endDate, yearGroups, monthGroups } = useMemo(() => {
|
||||
const now = currentDate;
|
||||
|
||||
if (viewMode === 'day') {
|
||||
// 일 모드: 현재 월의 1일~말일
|
||||
const year = now.getFullYear();
|
||||
const month = now.getMonth();
|
||||
const daysInMonth = new Date(year, month + 1, 0).getDate();
|
||||
const cols = Array.from({ length: daysInMonth }, (_, i) => ({
|
||||
label: String(i + 1),
|
||||
date: new Date(year, month, i + 1),
|
||||
year,
|
||||
month,
|
||||
}));
|
||||
return {
|
||||
columns: cols,
|
||||
startDate: new Date(year, month, 1),
|
||||
endDate: new Date(year, month, daysInMonth),
|
||||
yearGroups: null,
|
||||
monthGroups: null,
|
||||
};
|
||||
} else if (viewMode === 'week') {
|
||||
// 주 모드: 현재 월 기준 전후 2개월 (총 12주)
|
||||
const year = now.getFullYear();
|
||||
const month = now.getMonth();
|
||||
|
||||
// 전월 1일부터 시작
|
||||
const startMonth = month === 0 ? 11 : month - 1;
|
||||
const startYear = month === 0 ? year - 1 : year;
|
||||
const periodStart = new Date(startYear, startMonth, 1);
|
||||
|
||||
// 다음월 말일까지
|
||||
const endMonth = month === 11 ? 0 : month + 1;
|
||||
const endYear = month === 11 ? year + 1 : year;
|
||||
const periodEnd = new Date(endYear, endMonth + 1, 0);
|
||||
|
||||
// 주차별 컬럼 생성 (월요일 시작)
|
||||
const cols: { label: string; date: Date; year: number; month: number; weekStart: Date; weekEnd: Date }[] = [];
|
||||
const tempDate = new Date(periodStart);
|
||||
|
||||
// 첫 번째 월요일 찾기
|
||||
while (tempDate.getDay() !== 1) {
|
||||
tempDate.setDate(tempDate.getDate() + 1);
|
||||
}
|
||||
|
||||
let weekNum = 1;
|
||||
while (tempDate <= periodEnd) {
|
||||
const weekStart = new Date(tempDate);
|
||||
const weekEnd = new Date(tempDate);
|
||||
weekEnd.setDate(weekEnd.getDate() + 6);
|
||||
|
||||
cols.push({
|
||||
label: `${weekNum}주`,
|
||||
date: new Date(tempDate),
|
||||
year: tempDate.getFullYear(),
|
||||
month: tempDate.getMonth(),
|
||||
weekStart,
|
||||
weekEnd,
|
||||
});
|
||||
|
||||
tempDate.setDate(tempDate.getDate() + 7);
|
||||
weekNum++;
|
||||
}
|
||||
|
||||
// 월별 그룹 계산
|
||||
const monthGroupsMap = new Map<string, number>();
|
||||
cols.forEach((col) => {
|
||||
const key = `${col.year}-${col.month}`;
|
||||
monthGroupsMap.set(key, (monthGroupsMap.get(key) || 0) + 1);
|
||||
});
|
||||
|
||||
const mGroups = Array.from(monthGroupsMap.entries()).map(([key, count]) => {
|
||||
const [y, m] = key.split('-').map(Number);
|
||||
return { year: y, month: m, count, label: `${m + 1}월` };
|
||||
});
|
||||
|
||||
return {
|
||||
columns: cols,
|
||||
startDate: cols[0]?.weekStart || periodStart,
|
||||
endDate: cols[cols.length - 1]?.weekEnd || periodEnd,
|
||||
yearGroups: null,
|
||||
monthGroups: mGroups,
|
||||
};
|
||||
} else {
|
||||
// 월 모드: 전년도 + 올해 (2년치, 24개월)
|
||||
const year = now.getFullYear();
|
||||
const prevYear = year - 1;
|
||||
const cols: { label: string; date: Date; year: number; month: number }[] = [];
|
||||
|
||||
// 전년도 12개월
|
||||
for (let i = 0; i < 12; i++) {
|
||||
cols.push({
|
||||
label: `${i + 1}월`,
|
||||
date: new Date(prevYear, i, 1),
|
||||
year: prevYear,
|
||||
month: i,
|
||||
});
|
||||
}
|
||||
// 올해 12개월
|
||||
for (let i = 0; i < 12; i++) {
|
||||
cols.push({
|
||||
label: `${i + 1}월`,
|
||||
date: new Date(year, i, 1),
|
||||
year: year,
|
||||
month: i,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
columns: cols,
|
||||
startDate: new Date(prevYear, 0, 1),
|
||||
endDate: new Date(year, 11, 31),
|
||||
yearGroups: [
|
||||
{ year: prevYear, count: 12 },
|
||||
{ year: year, count: 12 },
|
||||
],
|
||||
monthGroups: null,
|
||||
};
|
||||
}
|
||||
}, [viewMode, currentDate]);
|
||||
|
||||
// 막대 위치 및 너비 계산
|
||||
const getBarStyle = (project: Project) => {
|
||||
const projectStart = new Date(project.startDate);
|
||||
const projectEnd = new Date(project.endDate);
|
||||
|
||||
// 범위 밖이면 표시 안함
|
||||
if (projectEnd < startDate || projectStart > endDate) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 시작/종료 위치 계산
|
||||
const totalDays = (endDate.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24);
|
||||
const barStartDays = Math.max(0, (projectStart.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24));
|
||||
const barEndDays = Math.min(totalDays, (projectEnd.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24));
|
||||
|
||||
const leftPercent = (barStartDays / totalDays) * 100;
|
||||
const widthPercent = ((barEndDays - barStartDays) / totalDays) * 100;
|
||||
|
||||
// 색상 결정
|
||||
let bgColor = GANTT_BAR_COLORS.in_progress;
|
||||
if (project.status === 'completed') {
|
||||
bgColor = GANTT_BAR_COLORS.completed;
|
||||
} else if (project.hasUrgentIssue || project.status === 'urgent') {
|
||||
bgColor = GANTT_BAR_COLORS.urgent;
|
||||
}
|
||||
|
||||
return {
|
||||
left: `${leftPercent}%`,
|
||||
width: `${Math.max(widthPercent, 1)}%`,
|
||||
backgroundColor: bgColor,
|
||||
};
|
||||
};
|
||||
|
||||
// 이전/다음 이동
|
||||
const handlePrev = () => {
|
||||
const newDate = new Date(currentDate);
|
||||
if (viewMode === 'day') {
|
||||
newDate.setMonth(newDate.getMonth() - 1);
|
||||
} else if (viewMode === 'week') {
|
||||
newDate.setMonth(newDate.getMonth() - 1);
|
||||
} else {
|
||||
newDate.setFullYear(newDate.getFullYear() - 1);
|
||||
}
|
||||
onDateChange(newDate);
|
||||
};
|
||||
|
||||
const handleNext = () => {
|
||||
const newDate = new Date(currentDate);
|
||||
if (viewMode === 'day') {
|
||||
newDate.setMonth(newDate.getMonth() + 1);
|
||||
} else if (viewMode === 'week') {
|
||||
newDate.setMonth(newDate.getMonth() + 1);
|
||||
} else {
|
||||
newDate.setFullYear(newDate.getFullYear() + 1);
|
||||
}
|
||||
onDateChange(newDate);
|
||||
};
|
||||
|
||||
// 월 모드에서 올해 시작 위치로 스크롤
|
||||
useEffect(() => {
|
||||
if (scrollContainerRef.current && viewMode === 'month') {
|
||||
// 올해 1월 위치로 스크롤 (전년도 12개월 건너뛰기)
|
||||
const totalWidth = scrollContainerRef.current.scrollWidth;
|
||||
const scrollPosition = totalWidth / 2 - scrollContainerRef.current.clientWidth / 3;
|
||||
scrollContainerRef.current.scrollLeft = Math.max(0, scrollPosition);
|
||||
} else if (scrollContainerRef.current && viewMode === 'day') {
|
||||
const today = new Date();
|
||||
const dayOfMonth = today.getDate();
|
||||
const columnWidth = scrollContainerRef.current.scrollWidth / columns.length;
|
||||
const scrollPosition = (dayOfMonth - 1) * columnWidth - scrollContainerRef.current.clientWidth / 2;
|
||||
scrollContainerRef.current.scrollLeft = Math.max(0, scrollPosition);
|
||||
}
|
||||
}, [viewMode, columns.length]);
|
||||
|
||||
return (
|
||||
<div className="border rounded-lg bg-card">
|
||||
{/* 헤더: 날짜 네비게이션 */}
|
||||
<div className="flex items-center justify-between p-3 border-b">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="icon" onClick={handlePrev}>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<span className="text-sm font-medium min-w-[140px] text-center">
|
||||
{viewMode === 'day'
|
||||
? `${currentDate.getFullYear()}년 ${currentDate.getMonth() + 1}월`
|
||||
: viewMode === 'week'
|
||||
? `${currentDate.getFullYear()}년 ${currentDate.getMonth()}월 ~ ${currentDate.getMonth() + 2}월`
|
||||
: `${currentDate.getFullYear() - 1}년 ~ ${currentDate.getFullYear()}년`}
|
||||
</span>
|
||||
<Button variant="outline" size="icon" onClick={handleNext}>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 범례 */}
|
||||
<div className="flex items-center gap-4 text-xs">
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="w-3 h-3 rounded" style={{ backgroundColor: GANTT_BAR_COLORS.in_progress }} />
|
||||
<span>진행중</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="w-3 h-3 rounded" style={{ backgroundColor: GANTT_BAR_COLORS.completed }} />
|
||||
<span>종료</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="w-3 h-3 rounded" style={{ backgroundColor: GANTT_BAR_COLORS.urgent }} />
|
||||
<span>긴급 이슈</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 차트 영역 */}
|
||||
<div className="overflow-hidden">
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
className="overflow-x-auto"
|
||||
onMouseDown={() => setIsScrolling(true)}
|
||||
onMouseUp={() => setIsScrolling(false)}
|
||||
onMouseLeave={() => setIsScrolling(false)}
|
||||
>
|
||||
<div className={cn(
|
||||
viewMode === 'month' ? 'min-w-[1600px]' : viewMode === 'week' ? 'min-w-[1000px]' : 'min-w-[800px]'
|
||||
)}>
|
||||
{/* 전체를 하나의 세로 스크롤 영역으로 */}
|
||||
<div className="max-h-[450px] overflow-y-auto">
|
||||
{/* 연도 헤더 (월 모드에서만) */}
|
||||
{viewMode === 'month' && yearGroups && (
|
||||
<div className="flex bg-muted/50 sticky top-0 z-20">
|
||||
{yearGroups.map((group) => (
|
||||
<div
|
||||
key={group.year}
|
||||
className="flex-1 p-1.5 text-xs font-semibold text-center border-r border-border last:border-r-0"
|
||||
style={{ flex: group.count }}
|
||||
>
|
||||
{group.year}년
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 월 헤더 (주 모드에서만) */}
|
||||
{viewMode === 'week' && monthGroups && (
|
||||
<div className="flex bg-muted/50 sticky top-0 z-20">
|
||||
{monthGroups.map((group, idx) => (
|
||||
<div
|
||||
key={`${group.year}-${group.month}-${idx}`}
|
||||
className="flex-1 p-1.5 text-xs font-semibold text-center border-r border-border last:border-r-0"
|
||||
style={{ flex: group.count }}
|
||||
>
|
||||
{group.label}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 컬럼 헤더 - 날짜/주/월 */}
|
||||
<div className={cn(
|
||||
'flex bg-muted/30 sticky z-10',
|
||||
(viewMode === 'month' || viewMode === 'week') ? 'top-[30px]' : 'top-0'
|
||||
)}>
|
||||
{columns.map((col, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className={cn(
|
||||
'flex-1 p-2 text-xs text-center border-r border-border last:border-r-0',
|
||||
viewMode === 'day' && col.date.getDay() === 0 && 'text-red-500',
|
||||
viewMode === 'day' && col.date.getDay() === 6 && 'text-blue-500'
|
||||
)}
|
||||
>
|
||||
{col.label}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 프로젝트 행들 (가로선 없음) */}
|
||||
{projects.length === 0 ? (
|
||||
<div className="p-8 text-center text-muted-foreground">
|
||||
표시할 프로젝트가 없습니다.
|
||||
</div>
|
||||
) : (
|
||||
projects.map((project) => {
|
||||
const barStyle = getBarStyle(project);
|
||||
return (
|
||||
<div
|
||||
key={project.id}
|
||||
className="relative h-12 hover:bg-muted/10 cursor-pointer"
|
||||
onClick={() => !isScrolling && onProjectClick(project)}
|
||||
>
|
||||
{/* 그리드 세로선 */}
|
||||
<div className="absolute inset-0 flex">
|
||||
{columns.map((_, idx) => (
|
||||
<div key={idx} className="flex-1 border-r border-border last:border-r-0" />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 막대 - 프로젝트명 직접 표시 */}
|
||||
{barStyle && (
|
||||
<div
|
||||
className="absolute top-1/2 -translate-y-1/2 h-7 rounded text-xs text-white flex items-center px-2 truncate shadow-sm"
|
||||
style={barStyle}
|
||||
>
|
||||
<span className="truncate font-medium">
|
||||
[{project.partnerName}] {project.siteName} {project.progressRate}%
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,661 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useMemo, useCallback, useEffect, Fragment } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { FolderKanban, Pencil, ClipboardList, PlayCircle, CheckCircle2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { PageLayout } from '@/components/organisms/PageLayout';
|
||||
import { PageHeader } from '@/components/organisms/PageHeader';
|
||||
import { MobileCard } from '@/components/molecules/MobileCard';
|
||||
import { toast } from 'sonner';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { Project, ProjectStats, ChartViewMode, SelectOption } from './types';
|
||||
import { STATUS_OPTIONS, SORT_OPTIONS } from './types';
|
||||
import { DateRangeSelector } from '@/components/molecules/DateRangeSelector';
|
||||
import { format, startOfMonth, endOfMonth } from 'date-fns';
|
||||
import {
|
||||
getProjectList,
|
||||
getProjectStats,
|
||||
getPartnerOptions,
|
||||
getSiteOptions,
|
||||
getContractManagerOptions,
|
||||
getConstructionPMOptions,
|
||||
} from './actions';
|
||||
import ProjectGanttChart from './ProjectGanttChart';
|
||||
|
||||
// 다중 선택 셀렉트 컴포넌트
|
||||
function MultiSelectFilter({
|
||||
label,
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
options: SelectOption[];
|
||||
value: string[];
|
||||
onChange: (value: string[]) => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const handleToggle = (optionValue: string) => {
|
||||
if (optionValue === 'all') {
|
||||
onChange(['all']);
|
||||
} else {
|
||||
const newValue = value.includes(optionValue)
|
||||
? value.filter((v) => v !== optionValue && v !== 'all')
|
||||
: [...value.filter((v) => v !== 'all'), optionValue];
|
||||
onChange(newValue.length === 0 ? ['all'] : newValue);
|
||||
}
|
||||
};
|
||||
|
||||
const displayValue = value.includes('all') || value.length === 0
|
||||
? '전체'
|
||||
: value.length === 1
|
||||
? options.find((o) => o.value === value[0])?.label || value[0]
|
||||
: `${value.length}개 선택`;
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-[140px] justify-between text-left font-normal"
|
||||
onClick={() => setOpen(!open)}
|
||||
>
|
||||
<span className="truncate">{displayValue}</span>
|
||||
</Button>
|
||||
{open && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-40" onClick={() => setOpen(false)} />
|
||||
<div className="absolute top-full left-0 z-50 mt-1 w-[200px] rounded-md border bg-popover p-1 shadow-md">
|
||||
<div
|
||||
className="flex items-center gap-2 p-2 hover:bg-muted rounded cursor-pointer"
|
||||
onClick={() => handleToggle('all')}
|
||||
>
|
||||
<Checkbox checked={value.includes('all') || value.length === 0} />
|
||||
<span className="text-sm">전체</span>
|
||||
</div>
|
||||
{options.map((option) => (
|
||||
<div
|
||||
key={option.value}
|
||||
className="flex items-center gap-2 p-2 hover:bg-muted rounded cursor-pointer"
|
||||
onClick={() => handleToggle(option.value)}
|
||||
>
|
||||
<Checkbox checked={value.includes(option.value)} />
|
||||
<span className="text-sm">{option.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ProjectListClientProps {
|
||||
initialData?: Project[];
|
||||
initialStats?: ProjectStats;
|
||||
}
|
||||
|
||||
export default function ProjectListClient({ initialData = [], initialStats }: ProjectListClientProps) {
|
||||
const router = useRouter();
|
||||
|
||||
// 상태
|
||||
const [projects, setProjects] = useState<Project[]>(initialData);
|
||||
const [stats, setStats] = useState<ProjectStats>(
|
||||
initialStats ?? { total: 0, inProgress: 0, completed: 0 }
|
||||
);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
// 날짜 범위 (기간 선택)
|
||||
const [filterStartDate, setFilterStartDate] = useState(() => format(startOfMonth(new Date()), 'yyyy-MM-dd'));
|
||||
const [filterEndDate, setFilterEndDate] = useState(() => format(endOfMonth(new Date()), 'yyyy-MM-dd'));
|
||||
|
||||
// 간트차트 상태
|
||||
const [chartViewMode, setChartViewMode] = useState<ChartViewMode>('day');
|
||||
// TODO: 실제 API 연동 시 new Date()로 변경 (현재 목업 데이터가 2025년이라 임시 설정)
|
||||
const [chartDate, setChartDate] = useState(new Date(2025, 0, 15));
|
||||
const [chartPartnerFilter, setChartPartnerFilter] = useState<string[]>(['all']);
|
||||
const [chartSiteFilter, setChartSiteFilter] = useState<string[]>(['all']);
|
||||
|
||||
// 테이블 필터
|
||||
const [partnerFilter, setPartnerFilter] = useState<string[]>(['all']);
|
||||
const [contractManagerFilter, setContractManagerFilter] = useState<string[]>(['all']);
|
||||
const [pmFilter, setPmFilter] = useState<string[]>(['all']);
|
||||
const [statusFilter, setStatusFilter] = useState('all');
|
||||
const [sortBy, setSortBy] = useState<'latest' | 'progress' | 'register' | 'completion'>('latest');
|
||||
|
||||
// 필터 옵션들
|
||||
const [partnerOptions, setPartnerOptions] = useState<SelectOption[]>([]);
|
||||
const [siteOptions, setSiteOptions] = useState<SelectOption[]>([]);
|
||||
const [contractManagerOptions, setContractManagerOptions] = useState<SelectOption[]>([]);
|
||||
const [pmOptions, setPmOptions] = useState<SelectOption[]>([]);
|
||||
|
||||
// 테이블 상태
|
||||
const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set());
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const itemsPerPage = 20;
|
||||
|
||||
// 데이터 로드
|
||||
const loadData = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const [listResult, statsResult, partners, sites, managers, pms] = await Promise.all([
|
||||
getProjectList({
|
||||
partners: partnerFilter.includes('all') ? undefined : partnerFilter,
|
||||
contractManagers: contractManagerFilter.includes('all') ? undefined : contractManagerFilter,
|
||||
constructionPMs: pmFilter.includes('all') ? undefined : pmFilter,
|
||||
status: statusFilter === 'all' ? undefined : statusFilter,
|
||||
sortBy,
|
||||
size: 1000,
|
||||
}),
|
||||
getProjectStats(),
|
||||
getPartnerOptions(),
|
||||
getSiteOptions(),
|
||||
getContractManagerOptions(),
|
||||
getConstructionPMOptions(),
|
||||
]);
|
||||
|
||||
if (listResult.success && listResult.data) {
|
||||
setProjects(listResult.data.items);
|
||||
}
|
||||
if (statsResult.success && statsResult.data) {
|
||||
setStats(statsResult.data);
|
||||
}
|
||||
if (partners.success && partners.data) {
|
||||
setPartnerOptions(partners.data);
|
||||
}
|
||||
if (sites.success && sites.data) {
|
||||
setSiteOptions(sites.data);
|
||||
}
|
||||
if (managers.success && managers.data) {
|
||||
setContractManagerOptions(managers.data);
|
||||
}
|
||||
if (pms.success && pms.data) {
|
||||
setPmOptions(pms.data);
|
||||
}
|
||||
} catch {
|
||||
toast.error('데이터 로드에 실패했습니다.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [partnerFilter, contractManagerFilter, pmFilter, statusFilter, sortBy]);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [loadData]);
|
||||
|
||||
// 간트차트용 필터링된 프로젝트
|
||||
const chartProjects = useMemo(() => {
|
||||
return projects.filter((project) => {
|
||||
if (!chartPartnerFilter.includes('all') && !chartPartnerFilter.includes(project.partnerName)) {
|
||||
return false;
|
||||
}
|
||||
if (!chartSiteFilter.includes('all') && !chartSiteFilter.includes(project.siteName)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}, [projects, chartPartnerFilter, chartSiteFilter]);
|
||||
|
||||
// 페이지네이션
|
||||
const totalPages = Math.ceil(projects.length / itemsPerPage);
|
||||
const paginatedData = useMemo(() => {
|
||||
const start = (currentPage - 1) * itemsPerPage;
|
||||
return projects.slice(start, start + itemsPerPage);
|
||||
}, [projects, currentPage, itemsPerPage]);
|
||||
|
||||
const startIndex = (currentPage - 1) * itemsPerPage;
|
||||
|
||||
// 핸들러
|
||||
const handleToggleSelection = useCallback((id: string) => {
|
||||
setSelectedItems((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(id)) {
|
||||
newSet.delete(id);
|
||||
} else {
|
||||
newSet.add(id);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleToggleSelectAll = useCallback(() => {
|
||||
if (selectedItems.size === paginatedData.length) {
|
||||
setSelectedItems(new Set());
|
||||
} else {
|
||||
setSelectedItems(new Set(paginatedData.map((p) => p.id)));
|
||||
}
|
||||
}, [selectedItems.size, paginatedData]);
|
||||
|
||||
const handleRowClick = useCallback(
|
||||
(project: Project) => {
|
||||
router.push(`/ko/construction/project/management/${project.id}`);
|
||||
},
|
||||
[router]
|
||||
);
|
||||
|
||||
const handleEdit = useCallback(
|
||||
(e: React.MouseEvent, projectId: string) => {
|
||||
e.stopPropagation();
|
||||
router.push(`/ko/construction/project/management/${projectId}/edit`);
|
||||
},
|
||||
[router]
|
||||
);
|
||||
|
||||
const handleGanttProjectClick = useCallback(
|
||||
(project: Project) => {
|
||||
router.push(`/ko/construction/project/management/${project.id}`);
|
||||
},
|
||||
[router]
|
||||
);
|
||||
|
||||
// 금액 포맷
|
||||
const formatAmount = (amount: number) => {
|
||||
return amount.toLocaleString() + '원';
|
||||
};
|
||||
|
||||
// 날짜 포맷
|
||||
const formatDate = (dateStr: string) => {
|
||||
return dateStr.replace(/-/g, '.');
|
||||
};
|
||||
|
||||
// 상태 뱃지
|
||||
const getStatusBadge = (status: string, hasUrgentIssue: boolean) => {
|
||||
if (hasUrgentIssue) {
|
||||
return <Badge variant="destructive">긴급</Badge>;
|
||||
}
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
return <Badge variant="secondary">완료</Badge>;
|
||||
case 'in_progress':
|
||||
return <Badge variant="default">진행중</Badge>;
|
||||
default:
|
||||
return <Badge variant="outline">{status}</Badge>;
|
||||
}
|
||||
};
|
||||
|
||||
const allSelected = selectedItems.size === paginatedData.length && paginatedData.length > 0;
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
{/* 페이지 헤더 */}
|
||||
<PageHeader
|
||||
title="프로젝트 관리"
|
||||
description="계약 완료 시 자동 등록된 프로젝트를 관리합니다"
|
||||
icon={FolderKanban}
|
||||
/>
|
||||
|
||||
{/* 기간 선택 (달력 + 프리셋 버튼) */}
|
||||
<DateRangeSelector
|
||||
startDate={filterStartDate}
|
||||
endDate={filterEndDate}
|
||||
onStartDateChange={setFilterStartDate}
|
||||
onEndDateChange={setFilterEndDate}
|
||||
/>
|
||||
|
||||
{/* 상태 카드 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-blue-100 rounded-lg">
|
||||
<ClipboardList className="h-5 w-5 text-blue-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">전체 프로젝트</p>
|
||||
<p className="text-2xl font-bold">{stats.total}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-green-100 rounded-lg">
|
||||
<PlayCircle className="h-5 w-5 text-green-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">프로젝트 진행</p>
|
||||
<p className="text-2xl font-bold">{stats.inProgress}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-gray-100 rounded-lg">
|
||||
<CheckCircle2 className="h-5 w-5 text-gray-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">프로젝트 완료</p>
|
||||
<p className="text-2xl font-bold">{stats.completed}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 프로젝트 일정 간트차트 */}
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* 간트차트 상단 컨트롤 */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium">프로젝트 일정</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
{/* 일/주/월 전환 */}
|
||||
<div className="flex border rounded-md">
|
||||
<Button
|
||||
variant={chartViewMode === 'day' ? 'default' : 'ghost'}
|
||||
size="sm"
|
||||
className="rounded-r-none border-r-0"
|
||||
onClick={() => setChartViewMode('day')}
|
||||
>
|
||||
일
|
||||
</Button>
|
||||
<Button
|
||||
variant={chartViewMode === 'week' ? 'default' : 'ghost'}
|
||||
size="sm"
|
||||
className="rounded-none border-r-0"
|
||||
onClick={() => setChartViewMode('week')}
|
||||
>
|
||||
주
|
||||
</Button>
|
||||
<Button
|
||||
variant={chartViewMode === 'month' ? 'default' : 'ghost'}
|
||||
size="sm"
|
||||
className="rounded-l-none"
|
||||
onClick={() => setChartViewMode('month')}
|
||||
>
|
||||
월
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 거래처 필터 */}
|
||||
<MultiSelectFilter
|
||||
label="거래처"
|
||||
options={partnerOptions}
|
||||
value={chartPartnerFilter}
|
||||
onChange={setChartPartnerFilter}
|
||||
/>
|
||||
|
||||
{/* 현장 필터 */}
|
||||
<MultiSelectFilter
|
||||
label="현장"
|
||||
options={siteOptions}
|
||||
value={chartSiteFilter}
|
||||
onChange={setChartSiteFilter}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 간트차트 */}
|
||||
<ProjectGanttChart
|
||||
projects={chartProjects}
|
||||
viewMode={chartViewMode}
|
||||
currentDate={chartDate}
|
||||
onProjectClick={handleGanttProjectClick}
|
||||
onDateChange={setChartDate}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 테이블 영역 */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
{/* 테이블 헤더 (필터들) */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 mb-4">
|
||||
<span className="text-sm text-muted-foreground whitespace-nowrap">
|
||||
총 {projects.length}건
|
||||
</span>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
{/* 거래처 필터 */}
|
||||
<MultiSelectFilter
|
||||
label="거래처"
|
||||
options={partnerOptions}
|
||||
value={partnerFilter}
|
||||
onChange={(v) => {
|
||||
setPartnerFilter(v);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 계약담당자 필터 */}
|
||||
<MultiSelectFilter
|
||||
label="계약담당자"
|
||||
options={contractManagerOptions}
|
||||
value={contractManagerFilter}
|
||||
onChange={(v) => {
|
||||
setContractManagerFilter(v);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 공사PM 필터 */}
|
||||
<MultiSelectFilter
|
||||
label="공사PM"
|
||||
options={pmOptions}
|
||||
value={pmFilter}
|
||||
onChange={(v) => {
|
||||
setPmFilter(v);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 상태 필터 */}
|
||||
<Select value={statusFilter} onValueChange={(v) => { setStatusFilter(v); setCurrentPage(1); }}>
|
||||
<SelectTrigger className="w-[100px]">
|
||||
<SelectValue placeholder="전체" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{STATUS_OPTIONS.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* 정렬 */}
|
||||
<Select value={sortBy} onValueChange={(v) => setSortBy(v as typeof sortBy)}>
|
||||
<SelectTrigger className="w-[100px]">
|
||||
<SelectValue placeholder="최신순" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SORT_OPTIONS.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 데스크톱 테이블 */}
|
||||
<div className="hidden xl:block rounded-md border overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="h-14">
|
||||
<TableHead className="w-[50px] text-center">
|
||||
<Checkbox
|
||||
checked={allSelected}
|
||||
onCheckedChange={handleToggleSelectAll}
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead className="w-[60px] text-center">번호</TableHead>
|
||||
<TableHead className="w-[100px]">계약번호</TableHead>
|
||||
<TableHead className="w-[120px]">거래처</TableHead>
|
||||
<TableHead className="min-w-[150px]">현장명</TableHead>
|
||||
<TableHead className="w-[100px]">계약담당자</TableHead>
|
||||
<TableHead className="w-[100px]">공사PM</TableHead>
|
||||
<TableHead className="w-[80px] text-center">총 개소</TableHead>
|
||||
<TableHead className="w-[120px] text-right">계약금액</TableHead>
|
||||
<TableHead className="w-[80px] text-center">진행률</TableHead>
|
||||
<TableHead className="w-[120px] text-right">누계 기성</TableHead>
|
||||
<TableHead className="w-[180px] text-center">프로젝트 기간</TableHead>
|
||||
<TableHead className="w-[80px] text-center">상태</TableHead>
|
||||
<TableHead className="w-[80px] text-center">작업</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody className="[&_tr]:h-14 [&_tr]:min-h-[56px] [&_tr]:max-h-[56px]">
|
||||
{paginatedData.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={14} className="h-24 text-center">
|
||||
검색 결과가 없습니다.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
paginatedData.map((project, index) => {
|
||||
const isSelected = selectedItems.has(project.id);
|
||||
const globalIndex = startIndex + index + 1;
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
key={project.id}
|
||||
className="cursor-pointer hover:bg-muted/50"
|
||||
onClick={() => handleRowClick(project)}
|
||||
>
|
||||
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={() => handleToggleSelection(project.id)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-center text-muted-foreground">{globalIndex}</TableCell>
|
||||
<TableCell className="font-medium">{project.contractNumber}</TableCell>
|
||||
<TableCell>{project.partnerName}</TableCell>
|
||||
<TableCell>{project.siteName}</TableCell>
|
||||
<TableCell>{project.contractManager}</TableCell>
|
||||
<TableCell>{project.constructionPM}</TableCell>
|
||||
<TableCell className="text-center">{project.totalLocations}</TableCell>
|
||||
<TableCell className="text-right">{formatAmount(project.contractAmount)}</TableCell>
|
||||
<TableCell className="text-center">{project.progressRate}%</TableCell>
|
||||
<TableCell className="text-right">{formatAmount(project.accumulatedPayment)}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{formatDate(project.startDate)} ~ {formatDate(project.endDate)}
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{getStatusBadge(project.status, project.hasUrgentIssue)}
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{isSelected && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={(e) => handleEdit(e, project.id)}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* 모바일/태블릿 카드 뷰 */}
|
||||
<div className="xl:hidden space-y-4 md:grid md:grid-cols-2 md:gap-4 lg:grid-cols-3">
|
||||
{projects.length === 0 ? (
|
||||
<div className="text-center py-6 text-muted-foreground border rounded-lg">
|
||||
검색 결과가 없습니다.
|
||||
</div>
|
||||
) : (
|
||||
projects.map((project, index) => {
|
||||
const isSelected = selectedItems.has(project.id);
|
||||
return (
|
||||
<MobileCard
|
||||
key={project.id}
|
||||
title={project.siteName}
|
||||
subtitle={project.contractNumber}
|
||||
badge={project.hasUrgentIssue ? '긴급' : project.status === 'completed' ? '완료' : '진행중'}
|
||||
badgeVariant={project.hasUrgentIssue ? 'destructive' : project.status === 'completed' ? 'secondary' : 'default'}
|
||||
isSelected={isSelected}
|
||||
onToggle={() => handleToggleSelection(project.id)}
|
||||
onClick={() => handleRowClick(project)}
|
||||
details={[
|
||||
{ label: '거래처', value: project.partnerName },
|
||||
{ label: '공사PM', value: project.constructionPM },
|
||||
{ label: '진행률', value: `${project.progressRate}%` },
|
||||
{ label: '계약금액', value: formatAmount(project.contractAmount) },
|
||||
]}
|
||||
/>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 페이지네이션 */}
|
||||
{totalPages > 1 && (
|
||||
<div className="hidden xl:flex items-center justify-between">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
전체 {projects.length}개 중 {startIndex + 1}-{Math.min(startIndex + itemsPerPage, projects.length)}개 표시
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setCurrentPage(currentPage - 1)}
|
||||
disabled={currentPage === 1}
|
||||
>
|
||||
이전
|
||||
</Button>
|
||||
<div className="flex items-center gap-1">
|
||||
{Array.from({ length: totalPages }, (_, i) => i + 1).map((page) => {
|
||||
if (
|
||||
page === 1 ||
|
||||
page === totalPages ||
|
||||
(page >= currentPage - 2 && page <= currentPage + 2)
|
||||
) {
|
||||
return (
|
||||
<Button
|
||||
key={page}
|
||||
variant={page === currentPage ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setCurrentPage(page)}
|
||||
className="min-w-[36px]"
|
||||
>
|
||||
{page}
|
||||
</Button>
|
||||
);
|
||||
} else if (page === currentPage - 3 || page === currentPage + 3) {
|
||||
return <span key={page} className="px-2">...</span>;
|
||||
}
|
||||
return null;
|
||||
})}
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setCurrentPage(currentPage + 1)}
|
||||
disabled={currentPage === totalPages}
|
||||
>
|
||||
다음
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
429
src/components/business/construction/management/actions.ts
Normal file
429
src/components/business/construction/management/actions.ts
Normal file
@@ -0,0 +1,429 @@
|
||||
'use server';
|
||||
|
||||
import type { Project, ProjectStats, ProjectFilter, ProjectListResponse, SelectOption } from './types';
|
||||
|
||||
/**
|
||||
* 프로젝트 관리 Server Actions
|
||||
* TODO: 실제 API 연동 시 구현
|
||||
*/
|
||||
|
||||
// 목업 데이터
|
||||
const mockProjects: Project[] = [
|
||||
{
|
||||
id: '1',
|
||||
contractNumber: '121123',
|
||||
partnerName: '대한건설',
|
||||
siteName: '서울 강남 현장',
|
||||
contractManager: '홍길동',
|
||||
constructionPM: '김철수',
|
||||
totalLocations: 5,
|
||||
contractAmount: 150000000,
|
||||
progressRate: 100,
|
||||
accumulatedPayment: 150000000,
|
||||
startDate: '2024-11-01',
|
||||
endDate: '2025-03-31',
|
||||
status: 'completed',
|
||||
hasUrgentIssue: false,
|
||||
createdAt: '2024-11-01',
|
||||
updatedAt: '2025-03-31',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
contractNumber: '121124',
|
||||
partnerName: '삼성시공',
|
||||
siteName: '부산 해운대 현장',
|
||||
contractManager: '이영희',
|
||||
constructionPM: '박민수',
|
||||
totalLocations: 8,
|
||||
contractAmount: 200000000,
|
||||
progressRate: 85.2,
|
||||
accumulatedPayment: 170400000,
|
||||
startDate: '2024-12-15',
|
||||
endDate: '2025-06-30',
|
||||
status: 'in_progress',
|
||||
hasUrgentIssue: false,
|
||||
createdAt: '2024-12-15',
|
||||
updatedAt: '2025-01-10',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
contractNumber: '121125',
|
||||
partnerName: 'LG건설',
|
||||
siteName: '대전 유성 현장',
|
||||
contractManager: '최민수',
|
||||
constructionPM: '정대리',
|
||||
totalLocations: 3,
|
||||
contractAmount: 80000000,
|
||||
progressRate: 15.3,
|
||||
accumulatedPayment: 12240000,
|
||||
startDate: '2025-01-01',
|
||||
endDate: '2025-08-31',
|
||||
status: 'in_progress',
|
||||
hasUrgentIssue: false,
|
||||
createdAt: '2025-01-01',
|
||||
updatedAt: '2025-01-10',
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
contractNumber: '121126',
|
||||
partnerName: '현대건설',
|
||||
siteName: '인천 송도 현장',
|
||||
contractManager: '강과장',
|
||||
constructionPM: '윤대리',
|
||||
totalLocations: 12,
|
||||
contractAmount: 350000000,
|
||||
progressRate: 86.5,
|
||||
accumulatedPayment: 302750000,
|
||||
startDate: '2024-10-01',
|
||||
endDate: '2025-05-31',
|
||||
status: 'in_progress',
|
||||
hasUrgentIssue: false,
|
||||
createdAt: '2024-10-01',
|
||||
updatedAt: '2025-01-10',
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
contractNumber: '121127',
|
||||
partnerName: 'SK건설',
|
||||
siteName: '광주 북구 현장',
|
||||
contractManager: '임부장',
|
||||
constructionPM: '오차장',
|
||||
totalLocations: 6,
|
||||
contractAmount: 120000000,
|
||||
progressRate: 86.5,
|
||||
accumulatedPayment: 103800000,
|
||||
startDate: '2024-09-15',
|
||||
endDate: '2025-04-30',
|
||||
status: 'in_progress',
|
||||
hasUrgentIssue: false,
|
||||
createdAt: '2024-09-15',
|
||||
updatedAt: '2025-01-10',
|
||||
},
|
||||
{
|
||||
id: '6',
|
||||
contractNumber: '121128',
|
||||
partnerName: '포스코건설',
|
||||
siteName: '울산 남구 현장',
|
||||
contractManager: '서이사',
|
||||
constructionPM: '한부장',
|
||||
totalLocations: 4,
|
||||
contractAmount: 95000000,
|
||||
progressRate: 85.3,
|
||||
accumulatedPayment: 81035000,
|
||||
startDate: '2024-11-20',
|
||||
endDate: '2025-07-31',
|
||||
status: 'urgent',
|
||||
hasUrgentIssue: true,
|
||||
createdAt: '2024-11-20',
|
||||
updatedAt: '2025-01-10',
|
||||
},
|
||||
// 스크롤 테스트용 추가 데이터
|
||||
{
|
||||
id: '7',
|
||||
contractNumber: '121129',
|
||||
partnerName: '롯데건설',
|
||||
siteName: '제주 서귀포 현장',
|
||||
contractManager: '김사장',
|
||||
constructionPM: '이과장',
|
||||
totalLocations: 7,
|
||||
contractAmount: 180000000,
|
||||
progressRate: 45.0,
|
||||
accumulatedPayment: 81000000,
|
||||
startDate: '2024-12-01',
|
||||
endDate: '2025-09-30',
|
||||
status: 'in_progress',
|
||||
hasUrgentIssue: false,
|
||||
createdAt: '2024-12-01',
|
||||
updatedAt: '2025-01-10',
|
||||
},
|
||||
{
|
||||
id: '8',
|
||||
contractNumber: '121130',
|
||||
partnerName: 'GS건설',
|
||||
siteName: '수원 영통 현장',
|
||||
contractManager: '박대리',
|
||||
constructionPM: '최부장',
|
||||
totalLocations: 10,
|
||||
contractAmount: 280000000,
|
||||
progressRate: 62.8,
|
||||
accumulatedPayment: 175840000,
|
||||
startDate: '2024-11-15',
|
||||
endDate: '2025-06-15',
|
||||
status: 'in_progress',
|
||||
hasUrgentIssue: false,
|
||||
createdAt: '2024-11-15',
|
||||
updatedAt: '2025-01-10',
|
||||
},
|
||||
{
|
||||
id: '9',
|
||||
contractNumber: '121131',
|
||||
partnerName: '한화건설',
|
||||
siteName: '대구 동구 현장',
|
||||
contractManager: '정이사',
|
||||
constructionPM: '송대리',
|
||||
totalLocations: 5,
|
||||
contractAmount: 140000000,
|
||||
progressRate: 78.5,
|
||||
accumulatedPayment: 109900000,
|
||||
startDate: '2024-10-20',
|
||||
endDate: '2025-04-20',
|
||||
status: 'in_progress',
|
||||
hasUrgentIssue: false,
|
||||
createdAt: '2024-10-20',
|
||||
updatedAt: '2025-01-10',
|
||||
},
|
||||
{
|
||||
id: '10',
|
||||
contractNumber: '121132',
|
||||
partnerName: '대우건설',
|
||||
siteName: '성남 분당 현장',
|
||||
contractManager: '윤차장',
|
||||
constructionPM: '장과장',
|
||||
totalLocations: 15,
|
||||
contractAmount: 420000000,
|
||||
progressRate: 35.0,
|
||||
accumulatedPayment: 147000000,
|
||||
startDate: '2025-01-05',
|
||||
endDate: '2025-12-31',
|
||||
status: 'in_progress',
|
||||
hasUrgentIssue: false,
|
||||
createdAt: '2025-01-05',
|
||||
updatedAt: '2025-01-10',
|
||||
},
|
||||
{
|
||||
id: '11',
|
||||
contractNumber: '121133',
|
||||
partnerName: '두산건설',
|
||||
siteName: '청주 흥덕 현장',
|
||||
contractManager: '안부장',
|
||||
constructionPM: '허대리',
|
||||
totalLocations: 6,
|
||||
contractAmount: 160000000,
|
||||
progressRate: 92.0,
|
||||
accumulatedPayment: 147200000,
|
||||
startDate: '2024-08-01',
|
||||
endDate: '2025-02-28',
|
||||
status: 'in_progress',
|
||||
hasUrgentIssue: false,
|
||||
createdAt: '2024-08-01',
|
||||
updatedAt: '2025-01-10',
|
||||
},
|
||||
{
|
||||
id: '12',
|
||||
contractNumber: '121134',
|
||||
partnerName: '태영건설',
|
||||
siteName: '천안 서북 현장',
|
||||
contractManager: '문과장',
|
||||
constructionPM: '남차장',
|
||||
totalLocations: 8,
|
||||
contractAmount: 220000000,
|
||||
progressRate: 55.5,
|
||||
accumulatedPayment: 122100000,
|
||||
startDate: '2024-12-20',
|
||||
endDate: '2025-08-20',
|
||||
status: 'urgent',
|
||||
hasUrgentIssue: true,
|
||||
createdAt: '2024-12-20',
|
||||
updatedAt: '2025-01-10',
|
||||
},
|
||||
];
|
||||
|
||||
// 프로젝트 목록 조회
|
||||
export async function getProjectList(
|
||||
filter?: ProjectFilter
|
||||
): Promise<{ success: boolean; data?: ProjectListResponse; error?: string }> {
|
||||
try {
|
||||
let filtered = [...mockProjects];
|
||||
|
||||
// 거래처 필터 (다중선택)
|
||||
if (filter?.partners && filter.partners.length > 0 && !filter.partners.includes('all')) {
|
||||
filtered = filtered.filter((p) => filter.partners!.includes(p.partnerName));
|
||||
}
|
||||
|
||||
// 현장 필터 (다중선택)
|
||||
if (filter?.sites && filter.sites.length > 0 && !filter.sites.includes('all')) {
|
||||
filtered = filtered.filter((p) => filter.sites!.includes(p.siteName));
|
||||
}
|
||||
|
||||
// 계약담당자 필터 (다중선택)
|
||||
if (filter?.contractManagers && filter.contractManagers.length > 0 && !filter.contractManagers.includes('all')) {
|
||||
filtered = filtered.filter((p) => filter.contractManagers!.includes(p.contractManager));
|
||||
}
|
||||
|
||||
// 공사PM 필터 (다중선택)
|
||||
if (filter?.constructionPMs && filter.constructionPMs.length > 0 && !filter.constructionPMs.includes('all')) {
|
||||
filtered = filtered.filter((p) => filter.constructionPMs!.includes(p.constructionPM));
|
||||
}
|
||||
|
||||
// 상태 필터 (단일선택)
|
||||
if (filter?.status && filter.status !== 'all') {
|
||||
filtered = filtered.filter((p) => p.status === filter.status);
|
||||
}
|
||||
|
||||
// 정렬
|
||||
if (filter?.sortBy) {
|
||||
switch (filter.sortBy) {
|
||||
case 'latest':
|
||||
filtered.sort((a, b) => new Date(b.startDate).getTime() - new Date(a.startDate).getTime());
|
||||
break;
|
||||
case 'progress':
|
||||
filtered.sort((a, b) => b.progressRate - a.progressRate);
|
||||
break;
|
||||
case 'register':
|
||||
filtered.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
|
||||
break;
|
||||
case 'completion':
|
||||
filtered.sort((a, b) => new Date(a.endDate).getTime() - new Date(b.endDate).getTime());
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const page = filter?.page ?? 1;
|
||||
const size = filter?.size ?? 20;
|
||||
const start = (page - 1) * size;
|
||||
const paginatedItems = filtered.slice(start, start + size);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
items: paginatedItems,
|
||||
total: filtered.length,
|
||||
page,
|
||||
size,
|
||||
totalPages: Math.ceil(filtered.length / size),
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('getProjectList error:', error);
|
||||
return { success: false, error: '프로젝트 목록 조회에 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// 프로젝트 상세 조회
|
||||
export async function getProject(
|
||||
id: string
|
||||
): Promise<{ success: boolean; data?: Project; error?: string }> {
|
||||
try {
|
||||
const project = mockProjects.find((p) => p.id === id);
|
||||
|
||||
if (!project) {
|
||||
return { success: false, error: '프로젝트를 찾을 수 없습니다.' };
|
||||
}
|
||||
|
||||
return { success: true, data: project };
|
||||
} catch (error) {
|
||||
console.error('getProject error:', error);
|
||||
return { success: false, error: '프로젝트 조회에 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// 프로젝트 통계 조회
|
||||
export async function getProjectStats(): Promise<{ success: boolean; data?: ProjectStats; error?: string }> {
|
||||
try {
|
||||
const total = mockProjects.length;
|
||||
const completed = mockProjects.filter((p) => p.status === 'completed').length;
|
||||
const inProgress = mockProjects.filter((p) => p.status === 'in_progress' || p.status === 'urgent').length;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
total,
|
||||
inProgress,
|
||||
completed,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('getProjectStats error:', error);
|
||||
return { success: false, error: '통계 조회에 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// 거래처 목록 조회 (필터용)
|
||||
export async function getPartnerOptions(): Promise<{ success: boolean; data?: SelectOption[]; error?: string }> {
|
||||
try {
|
||||
const partners = [...new Set(mockProjects.map((p) => p.partnerName))];
|
||||
const options: SelectOption[] = partners.map((name) => ({
|
||||
value: name,
|
||||
label: name,
|
||||
}));
|
||||
|
||||
return { success: true, data: options };
|
||||
} catch (error) {
|
||||
console.error('getPartnerOptions error:', error);
|
||||
return { success: false, error: '거래처 목록 조회에 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// 현장 목록 조회 (필터용)
|
||||
export async function getSiteOptions(): Promise<{ success: boolean; data?: SelectOption[]; error?: string }> {
|
||||
try {
|
||||
const sites = [...new Set(mockProjects.map((p) => p.siteName))];
|
||||
const options: SelectOption[] = sites.map((name) => ({
|
||||
value: name,
|
||||
label: name,
|
||||
}));
|
||||
|
||||
return { success: true, data: options };
|
||||
} catch (error) {
|
||||
console.error('getSiteOptions error:', error);
|
||||
return { success: false, error: '현장 목록 조회에 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// 계약담당자 목록 조회 (필터용)
|
||||
export async function getContractManagerOptions(): Promise<{ success: boolean; data?: SelectOption[]; error?: string }> {
|
||||
try {
|
||||
const managers = [...new Set(mockProjects.map((p) => p.contractManager))];
|
||||
const options: SelectOption[] = managers.map((name) => ({
|
||||
value: name,
|
||||
label: name,
|
||||
}));
|
||||
|
||||
return { success: true, data: options };
|
||||
} catch (error) {
|
||||
console.error('getContractManagerOptions error:', error);
|
||||
return { success: false, error: '계약담당자 목록 조회에 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// 공사PM 목록 조회 (필터용)
|
||||
export async function getConstructionPMOptions(): Promise<{ success: boolean; data?: SelectOption[]; error?: string }> {
|
||||
try {
|
||||
const pms = [...new Set(mockProjects.map((p) => p.constructionPM))];
|
||||
const options: SelectOption[] = pms.map((name) => ({
|
||||
value: name,
|
||||
label: name,
|
||||
}));
|
||||
|
||||
return { success: true, data: options };
|
||||
} catch (error) {
|
||||
console.error('getConstructionPMOptions error:', error);
|
||||
return { success: false, error: '공사PM 목록 조회에 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// 프로젝트 수정
|
||||
export async function updateProject(
|
||||
id: string,
|
||||
data: Partial<Project>
|
||||
): Promise<{ success: boolean; data?: Project; error?: string }> {
|
||||
try {
|
||||
console.log('Update project:', id, data);
|
||||
|
||||
const existingProject = mockProjects.find((p) => p.id === id);
|
||||
if (!existingProject) {
|
||||
return { success: false, error: '프로젝트를 찾을 수 없습니다.' };
|
||||
}
|
||||
|
||||
const updatedProject: Project = {
|
||||
...existingProject,
|
||||
...data,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
return { success: true, data: updatedProject };
|
||||
} catch (error) {
|
||||
console.error('updateProject error:', error);
|
||||
return { success: false, error: '프로젝트 수정에 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
4
src/components/business/construction/management/index.ts
Normal file
4
src/components/business/construction/management/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export { default as ProjectListClient } from './ProjectListClient';
|
||||
export { default as ProjectGanttChart } from './ProjectGanttChart';
|
||||
export * from './types';
|
||||
export * from './actions';
|
||||
98
src/components/business/construction/management/types.ts
Normal file
98
src/components/business/construction/management/types.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* 프로젝트 관리 타입 정의
|
||||
*/
|
||||
|
||||
// 프로젝트 상태
|
||||
export type ProjectStatus = 'in_progress' | 'completed' | 'urgent';
|
||||
|
||||
// 프로젝트 타입
|
||||
export interface Project {
|
||||
id: string;
|
||||
contractNumber: string; // 계약번호
|
||||
partnerName: string; // 거래처명
|
||||
siteName: string; // 현장명
|
||||
contractManager: string; // 계약담당자
|
||||
constructionPM: string; // 공사PM
|
||||
totalLocations: number; // 총 개소
|
||||
contractAmount: number; // 계약금액
|
||||
progressRate: number; // 진행률 (0-100)
|
||||
accumulatedPayment: number; // 누계 기성
|
||||
startDate: string; // 프로젝트 시작일
|
||||
endDate: string; // 프로젝트 종료일
|
||||
status: ProjectStatus; // 상태
|
||||
hasUrgentIssue: boolean; // 긴급 이슈 여부
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
// 프로젝트 통계
|
||||
export interface ProjectStats {
|
||||
total: number; // 전체 프로젝트
|
||||
inProgress: number; // 프로젝트 진행
|
||||
completed: number; // 프로젝트 완료
|
||||
}
|
||||
|
||||
// 프로젝트 필터
|
||||
export interface ProjectFilter {
|
||||
partners?: string[]; // 거래처 (다중선택)
|
||||
sites?: string[]; // 현장 (다중선택)
|
||||
contractManagers?: string[]; // 계약담당자 (다중선택)
|
||||
constructionPMs?: string[]; // 공사PM (다중선택)
|
||||
status?: string; // 상태 (단일선택)
|
||||
sortBy?: 'latest' | 'progress' | 'register' | 'completion'; // 정렬
|
||||
page?: number;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
// 기간 탭 타입
|
||||
export type PeriodTab = 'thisYear' | 'twoMonthsAgo' | 'lastMonth' | 'thisMonth' | 'yesterday' | 'today';
|
||||
|
||||
// 차트 뷰 모드
|
||||
export type ChartViewMode = 'day' | 'week' | 'month';
|
||||
|
||||
// API 응답 타입
|
||||
export interface ProjectListResponse {
|
||||
items: Project[];
|
||||
total: number;
|
||||
page: number;
|
||||
size: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
// 셀렉트 옵션
|
||||
export interface SelectOption {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
// 기간 탭 옵션
|
||||
export const PERIOD_TAB_OPTIONS: { value: PeriodTab; label: string }[] = [
|
||||
{ value: 'thisYear', label: '당해년도' },
|
||||
{ value: 'twoMonthsAgo', label: '전전월' },
|
||||
{ value: 'lastMonth', label: '전월' },
|
||||
{ value: 'thisMonth', label: '당월' },
|
||||
{ value: 'yesterday', label: '어제' },
|
||||
{ value: 'today', label: '오늘' },
|
||||
];
|
||||
|
||||
// 상태 옵션
|
||||
export const STATUS_OPTIONS: SelectOption[] = [
|
||||
{ value: 'all', label: '전체' },
|
||||
{ value: 'in_progress', label: '진행중' },
|
||||
{ value: 'completed', label: '완료' },
|
||||
];
|
||||
|
||||
// 정렬 옵션
|
||||
export const SORT_OPTIONS: SelectOption[] = [
|
||||
{ value: 'latest', label: '최신순' },
|
||||
{ value: 'progress', label: '진전순' },
|
||||
{ value: 'register', label: '등록순' },
|
||||
{ value: 'completion', label: '완성일순' },
|
||||
];
|
||||
|
||||
// 간트차트 막대 색상
|
||||
export const GANTT_BAR_COLORS = {
|
||||
completed: '#9CA3AF', // 회색 - 종료
|
||||
in_progress: '#3B82F6', // 파란색 - 진행중
|
||||
urgent: '#991B1B', // 버건디 - 긴급 이슈
|
||||
} as const;
|
||||
@@ -802,7 +802,7 @@ export default function OrderManagementListClient({
|
||||
onMonthChange={handleCalendarMonthChange}
|
||||
titleSlot="발주 스케줄"
|
||||
filterSlot={calendarFilterSlot}
|
||||
maxEventsPerDay={3}
|
||||
maxEventsPerDay={5}
|
||||
weekStartsOn={0}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
|
||||
@@ -32,7 +32,7 @@ export function OrderScheduleCard({
|
||||
onDateClick={onDateClick}
|
||||
onEventClick={() => {}}
|
||||
onMonthChange={onMonthChange}
|
||||
maxEventsPerDay={3}
|
||||
maxEventsPerDay={5}
|
||||
weekStartsOn={0}
|
||||
isLoading={false}
|
||||
/>
|
||||
|
||||
@@ -11,6 +11,7 @@ interface DayCellProps {
|
||||
isToday: boolean;
|
||||
isSelected: boolean;
|
||||
isWeekend: boolean;
|
||||
isPast: boolean;
|
||||
badge?: DayBadge;
|
||||
onClick: (date: Date) => void;
|
||||
}
|
||||
@@ -28,6 +29,7 @@ export function DayCell({
|
||||
isToday,
|
||||
isSelected,
|
||||
isWeekend,
|
||||
isPast,
|
||||
badge,
|
||||
onClick,
|
||||
}: DayCellProps) {
|
||||
@@ -44,11 +46,15 @@ export function DayCell({
|
||||
'hover:bg-primary/10',
|
||||
// 현재 월 여부
|
||||
isCurrentMonth ? 'text-foreground' : 'text-muted-foreground/40',
|
||||
// 주말 색상
|
||||
isWeekend && isCurrentMonth && 'text-red-500',
|
||||
// 오늘
|
||||
isToday && 'bg-accent text-accent-foreground font-bold',
|
||||
// 선택됨
|
||||
// 지난 일자 - 더 명확한 회색 (현재 월에서만)
|
||||
isPast && isCurrentMonth && !isToday && !isSelected && 'text-gray-400',
|
||||
// 주말 색상 (지난 일자가 아닌 경우만)
|
||||
isWeekend && isCurrentMonth && !isPast && 'text-red-500',
|
||||
// 지난 주말 - 연한 색상
|
||||
isWeekend && isCurrentMonth && isPast && !isToday && !isSelected && 'text-red-300',
|
||||
// 오늘 - 굵은 글씨 (외곽선은 부모 셀에 적용)
|
||||
isToday && !isSelected && 'font-bold text-primary',
|
||||
// 선택됨 - 배경색 하이라이트
|
||||
isSelected && 'bg-primary text-primary-foreground hover:bg-primary'
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
getWeekdayHeaders,
|
||||
isCurrentMonth,
|
||||
checkIsToday,
|
||||
checkIsPast,
|
||||
isSameDate,
|
||||
splitIntoWeeks,
|
||||
getEventSegmentsForWeek,
|
||||
@@ -173,6 +174,10 @@ function WeekRow({
|
||||
const dayEvents = getEventsForDate(events, date);
|
||||
const isSelected = isSameDate(selectedDate, date);
|
||||
|
||||
const isToday = checkIsToday(date);
|
||||
const isPast = checkIsPast(date);
|
||||
const isCurrMonth = isCurrentMonth(date, currentDate);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={date.toISOString()}
|
||||
@@ -180,7 +185,11 @@ function WeekRow({
|
||||
'relative p-1 border-r last:border-r-0',
|
||||
'flex flex-col cursor-pointer transition-colors',
|
||||
// 기본 배경
|
||||
!isCurrentMonth(date, currentDate) && 'bg-muted/30',
|
||||
!isCurrMonth && 'bg-muted/30',
|
||||
// 지난 일자 - 회색 배경 (현재 월, 오늘/선택 제외)
|
||||
isPast && isCurrMonth && !isToday && !isSelected && 'bg-gray-200 dark:bg-gray-700',
|
||||
// 오늘 - 셀 전체 외곽선 하이라이트
|
||||
isToday && !isSelected && 'ring-2 ring-primary ring-inset',
|
||||
// 선택된 날짜 - 셀 전체 배경색 변경 (테두리 없이)
|
||||
isSelected && 'bg-primary/15'
|
||||
)}
|
||||
@@ -189,10 +198,11 @@ function WeekRow({
|
||||
{/* 날짜 셀 */}
|
||||
<DayCell
|
||||
date={date}
|
||||
isCurrentMonth={isCurrentMonth(date, currentDate)}
|
||||
isToday={checkIsToday(date)}
|
||||
isCurrentMonth={isCurrMonth}
|
||||
isToday={isToday}
|
||||
isSelected={isSelected}
|
||||
isWeekend={isWeekend}
|
||||
isPast={isPast}
|
||||
badge={badge}
|
||||
onClick={onDateClick}
|
||||
/>
|
||||
|
||||
@@ -40,7 +40,7 @@ export function ScheduleCalendar({
|
||||
onViewChange,
|
||||
titleSlot,
|
||||
filterSlot,
|
||||
maxEventsPerDay = 3,
|
||||
maxEventsPerDay = 5,
|
||||
weekStartsOn = 0,
|
||||
isLoading = false,
|
||||
className,
|
||||
|
||||
@@ -7,10 +7,12 @@ import {
|
||||
endOfMonth,
|
||||
startOfWeek,
|
||||
endOfWeek,
|
||||
startOfDay,
|
||||
eachDayOfInterval,
|
||||
isSameMonth,
|
||||
isSameDay,
|
||||
isToday,
|
||||
isBefore,
|
||||
format,
|
||||
addMonths,
|
||||
subMonths,
|
||||
@@ -71,6 +73,15 @@ export function checkIsToday(date: Date): boolean {
|
||||
return isToday(date);
|
||||
}
|
||||
|
||||
/**
|
||||
* 날짜가 오늘 이전인지 확인 (지난 일자)
|
||||
*/
|
||||
export function checkIsPast(date: Date): boolean {
|
||||
const today = startOfDay(new Date());
|
||||
const targetDate = startOfDay(date);
|
||||
return isBefore(targetDate, today);
|
||||
}
|
||||
|
||||
/**
|
||||
* 두 날짜가 같은지 확인
|
||||
*/
|
||||
|
||||
128
src/components/quotes/ItemSearchModal.tsx
Normal file
128
src/components/quotes/ItemSearchModal.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* 품목 검색 모달
|
||||
*
|
||||
* - 품목 코드로 검색
|
||||
* - 품목 목록에서 선택
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo } from "react";
|
||||
import { Search, X } from "lucide-react";
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "../ui/dialog";
|
||||
import { Input } from "../ui/input";
|
||||
|
||||
// =============================================================================
|
||||
// 목데이터 - 품목 목록
|
||||
// =============================================================================
|
||||
|
||||
const MOCK_ITEMS = [
|
||||
{ code: "KSS01", name: "스크린", description: "방화스크린 기본형" },
|
||||
{ code: "KSS02", name: "스크린", description: "방화스크린 고급형" },
|
||||
{ code: "KSS03", name: "슬랫", description: "방화슬랫 기본형" },
|
||||
{ code: "KSS04", name: "스크린", description: "방화스크린 특수형" },
|
||||
];
|
||||
|
||||
// =============================================================================
|
||||
// Props
|
||||
// =============================================================================
|
||||
|
||||
interface ItemSearchModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSelectItem: (item: { code: string; name: string }) => void;
|
||||
tabLabel?: string;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 컴포넌트
|
||||
// =============================================================================
|
||||
|
||||
export function ItemSearchModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
onSelectItem,
|
||||
tabLabel,
|
||||
}: ItemSearchModalProps) {
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
|
||||
// 검색 필터링
|
||||
const filteredItems = useMemo(() => {
|
||||
if (!searchQuery) return MOCK_ITEMS;
|
||||
const query = searchQuery.toLowerCase();
|
||||
return MOCK_ITEMS.filter(
|
||||
(item) =>
|
||||
item.code.toLowerCase().includes(query) ||
|
||||
item.name.toLowerCase().includes(query) ||
|
||||
item.description.toLowerCase().includes(query)
|
||||
);
|
||||
}, [searchQuery]);
|
||||
|
||||
const handleSelect = (item: (typeof MOCK_ITEMS)[0]) => {
|
||||
onSelectItem({ code: item.code, name: item.name });
|
||||
onOpenChange(false);
|
||||
setSearchQuery("");
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[400px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>품목 검색</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{/* 검색 입력 */}
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||||
<Input
|
||||
placeholder="원하는 검색어..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
onClick={() => setSearchQuery("")}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2"
|
||||
>
|
||||
<X className="h-4 w-4 text-gray-400 hover:text-gray-600" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 품목 목록 */}
|
||||
<div className="max-h-[300px] overflow-y-auto border rounded-lg divide-y">
|
||||
{filteredItems.length === 0 ? (
|
||||
<div className="p-4 text-center text-gray-500 text-sm">
|
||||
검색 결과가 없습니다
|
||||
</div>
|
||||
) : (
|
||||
filteredItems.map((item) => (
|
||||
<div
|
||||
key={item.code}
|
||||
onClick={() => handleSelect(item)}
|
||||
className="p-3 hover:bg-blue-50 cursor-pointer transition-colors"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<span className="font-semibold text-gray-900">{item.code}</span>
|
||||
<span className="ml-2 text-sm text-gray-500">{item.name}</span>
|
||||
</div>
|
||||
</div>
|
||||
{item.description && (
|
||||
<p className="text-xs text-gray-400 mt-1">{item.description}</p>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
623
src/components/quotes/LocationDetailPanel.tsx
Normal file
623
src/components/quotes/LocationDetailPanel.tsx
Normal file
@@ -0,0 +1,623 @@
|
||||
/**
|
||||
* 선택 개소 상세 정보 패널
|
||||
*
|
||||
* - 제품 정보 (제품명, 오픈사이즈, 제작사이즈, 산출중량, 산출면적, 수량)
|
||||
* - 필수 설정 (가이드레일, 전원, 제어기)
|
||||
* - 탭: 본체(스크린/슬랫), 절곡품-가이드레일, 절곡품-케이스, 절곡품-하단마감재, 모터&제어기, 부자재
|
||||
* - 탭별 품목 테이블 (각 탭마다 다른 컬럼 구조)
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo } from "react";
|
||||
import { Package, Settings, Plus, Trash2 } from "lucide-react";
|
||||
|
||||
import { Badge } from "../ui/badge";
|
||||
import { Button } from "../ui/button";
|
||||
import { Input } from "../ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "../ui/select";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "../ui/table";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs";
|
||||
import { ItemSearchModal } from "./ItemSearchModal";
|
||||
|
||||
// 납품길이 옵션
|
||||
const DELIVERY_LENGTH_OPTIONS = [
|
||||
{ value: "3000", label: "3000" },
|
||||
{ value: "4000", label: "4000" },
|
||||
{ value: "5000", label: "5000" },
|
||||
{ value: "6000", label: "6000" },
|
||||
];
|
||||
|
||||
// 목데이터 - 탭별 품목 아이템 (각 탭마다 다른 구조)
|
||||
const MOCK_BOM_ITEMS = {
|
||||
// 본체 (스크린/슬랫): 품목명, 제작사이즈, 수량, 작업
|
||||
body: [
|
||||
{ id: "b1", item_name: "실리카 스크린", manufacture_size: "5280*3280", quantity: 1, unit: "EA", total_price: 1061676 },
|
||||
],
|
||||
// 절곡품 - 가이드레일: 품목명, 재질, 규격, 납품길이, 수량, 작업
|
||||
"guide-rail": [
|
||||
{ id: "g1", item_name: "벽면형 마감재", material: "알루미늄", spec: "50mm", delivery_length: "4000", quantity: 2, unit: "EA", total_price: 84048 },
|
||||
{ id: "g2", item_name: "본체 가이드 레일", material: "스틸", spec: "20mm", delivery_length: "4000", quantity: 2, unit: "EA", total_price: 32508 },
|
||||
],
|
||||
// 절곡품 - 케이스: 품목명, 재질, 규격, 납품길이, 수량, 작업
|
||||
case: [
|
||||
{ id: "c1", item_name: "전면부 케이스", material: "알루미늄", spec: "30mm", delivery_length: "4000", quantity: 1, unit: "EA", total_price: 30348 },
|
||||
],
|
||||
// 절곡품 - 하단마감재: 품목명, 재질, 규격, 납품길이, 수량, 작업
|
||||
bottom: [
|
||||
{ id: "bt1", item_name: "하단 하우징", material: "스틸", spec: "40mm", delivery_length: "4000", quantity: 1, unit: "EA", total_price: 15420 },
|
||||
],
|
||||
// 모터 & 제어기: 품목명, 유형, 사양, 수량, 작업
|
||||
motor: [
|
||||
{ id: "m1", item_name: "직류 모터", type: "220V", spec: "1/2HP", quantity: 1, unit: "EA", total_price: 250000 },
|
||||
{ id: "m2", item_name: "제어기", type: "디지털", spec: "", quantity: 1, unit: "EA", total_price: 150000 },
|
||||
],
|
||||
// 부자재: 품목명, 규격, 납품길이, 수량, 작업
|
||||
accessory: [
|
||||
{ id: "a1", item_name: "각파이프 25mm", spec: "25*25*2.0t", delivery_length: "4000", quantity: 2, unit: "EA", total_price: 17000 },
|
||||
{ id: "a2", item_name: "플랫바 20mm", spec: "20*3.0t", delivery_length: "4000", quantity: 1, unit: "EA", total_price: 4200 },
|
||||
],
|
||||
};
|
||||
|
||||
import type { LocationItem } from "./QuoteRegistrationV2";
|
||||
import type { FinishedGoods } from "./actions";
|
||||
|
||||
// =============================================================================
|
||||
// 상수
|
||||
// =============================================================================
|
||||
|
||||
// 가이드레일 설치 유형
|
||||
const GUIDE_RAIL_TYPES = [
|
||||
{ value: "wall", label: "벽면형" },
|
||||
{ value: "floor", label: "측면형" },
|
||||
];
|
||||
|
||||
// 모터 전원
|
||||
const MOTOR_POWERS = [
|
||||
{ value: "single", label: "단상(220V)" },
|
||||
{ value: "three", label: "삼상(380V)" },
|
||||
];
|
||||
|
||||
// 연동제어기
|
||||
const CONTROLLERS = [
|
||||
{ value: "basic", label: "단독" },
|
||||
{ value: "smart", label: "연동" },
|
||||
{ value: "premium", label: "매립형-뒷박스포함" },
|
||||
];
|
||||
|
||||
// 탭 정의 (6개)
|
||||
const DETAIL_TABS = [
|
||||
{ value: "body", label: "본체 (스크린/슬랫)" },
|
||||
{ value: "guide-rail", label: "절곡품 - 가이드레일" },
|
||||
{ value: "case", label: "절곡품 - 케이스" },
|
||||
{ value: "bottom", label: "절곡품 - 하단마감재" },
|
||||
{ value: "motor", label: "모터 & 제어기" },
|
||||
{ value: "accessory", label: "부자재" },
|
||||
];
|
||||
|
||||
// =============================================================================
|
||||
// Props
|
||||
// =============================================================================
|
||||
|
||||
interface LocationDetailPanelProps {
|
||||
location: LocationItem | null;
|
||||
onUpdateLocation: (locationId: string, updates: Partial<LocationItem>) => void;
|
||||
finishedGoods: FinishedGoods[];
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 컴포넌트
|
||||
// =============================================================================
|
||||
|
||||
export function LocationDetailPanel({
|
||||
location,
|
||||
onUpdateLocation,
|
||||
finishedGoods,
|
||||
disabled = false,
|
||||
}: LocationDetailPanelProps) {
|
||||
// ---------------------------------------------------------------------------
|
||||
// 상태
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const [activeTab, setActiveTab] = useState("body");
|
||||
const [itemSearchOpen, setItemSearchOpen] = useState(false);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 계산된 값
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// 제품 정보
|
||||
const product = useMemo(() => {
|
||||
if (!location?.productCode) return null;
|
||||
return finishedGoods.find((fg) => fg.item_code === location.productCode);
|
||||
}, [location?.productCode, finishedGoods]);
|
||||
|
||||
// BOM 아이템을 탭별로 분류 (목데이터 사용)
|
||||
const bomItemsByTab = useMemo(() => {
|
||||
// bomResult가 없으면 목데이터 사용
|
||||
if (!location?.bomResult?.items) {
|
||||
return MOCK_BOM_ITEMS;
|
||||
}
|
||||
|
||||
const items = location.bomResult.items;
|
||||
const result: Record<string, typeof items> = {
|
||||
body: [],
|
||||
"guide-rail": [],
|
||||
case: [],
|
||||
bottom: [],
|
||||
};
|
||||
|
||||
items.forEach((item) => {
|
||||
const processGroup = item.process_group?.toLowerCase() || "";
|
||||
|
||||
if (processGroup.includes("본체") || processGroup.includes("스크린") || processGroup.includes("슬랫")) {
|
||||
result.body.push(item);
|
||||
} else if (processGroup.includes("가이드") || processGroup.includes("레일")) {
|
||||
result["guide-rail"].push(item);
|
||||
} else if (processGroup.includes("케이스")) {
|
||||
result.case.push(item);
|
||||
} else if (processGroup.includes("하단") || processGroup.includes("마감")) {
|
||||
result.bottom.push(item);
|
||||
} else {
|
||||
// 기타 항목은 본체에 포함
|
||||
result.body.push(item);
|
||||
}
|
||||
});
|
||||
|
||||
return result;
|
||||
}, [location?.bomResult?.items]);
|
||||
|
||||
// 탭별 소계
|
||||
const tabSubtotals = useMemo(() => {
|
||||
const result: Record<string, number> = {};
|
||||
Object.entries(bomItemsByTab).forEach(([tab, items]) => {
|
||||
result[tab] = items.reduce((sum, item) => sum + (item.total_price || 0), 0);
|
||||
});
|
||||
return result;
|
||||
}, [bomItemsByTab]);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 핸들러
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const handleFieldChange = (field: keyof LocationItem, value: string | number) => {
|
||||
if (!location || disabled) return;
|
||||
onUpdateLocation(location.id, { [field]: value });
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 렌더링: 빈 상태
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
if (!location) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full bg-gray-50 text-gray-500">
|
||||
<Package className="h-12 w-12 mb-4 text-gray-300" />
|
||||
<p className="text-lg font-medium">개소를 선택해주세요</p>
|
||||
<p className="text-sm">왼쪽 목록에서 개소를 선택하면 상세 정보가 표시됩니다</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 렌더링: 상세 정보
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full overflow-hidden">
|
||||
{/* 헤더 */}
|
||||
<div className="bg-white px-4 py-3 border-b">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-bold">
|
||||
{location.floor} / {location.code}
|
||||
</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-gray-500">제품명:</span>
|
||||
<Badge variant="outline" className="bg-blue-50 text-blue-700 border-blue-200">
|
||||
{location.productCode}
|
||||
</Badge>
|
||||
{location.bomResult && (
|
||||
<Badge variant="default" className="bg-green-600">
|
||||
산출완료
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 제품 정보 */}
|
||||
<div className="bg-gray-50 px-4 py-3 border-b space-y-3">
|
||||
{/* 오픈사이즈 */}
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-sm text-gray-600 w-20">오픈사이즈</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
value={location.openWidth}
|
||||
onChange={(e) => handleFieldChange("openWidth", parseFloat(e.target.value) || 0)}
|
||||
disabled={disabled}
|
||||
className="w-24 h-8 text-center font-bold"
|
||||
/>
|
||||
<span className="text-gray-400">×</span>
|
||||
<Input
|
||||
type="number"
|
||||
value={location.openHeight}
|
||||
onChange={(e) => handleFieldChange("openHeight", parseFloat(e.target.value) || 0)}
|
||||
disabled={disabled}
|
||||
className="w-24 h-8 text-center font-bold"
|
||||
/>
|
||||
{!disabled && (
|
||||
<Badge variant="secondary" className="text-xs">수정</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 제작사이즈, 산출중량, 산출면적, 수량 */}
|
||||
<div className="grid grid-cols-4 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-gray-500">제작사이즈</span>
|
||||
<p className="font-semibold">
|
||||
{location.manufactureWidth || location.openWidth + 280} × {location.manufactureHeight || location.openHeight + 280}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500">산출중량</span>
|
||||
<p className="font-semibold">{location.weight?.toFixed(1) || "-"} <span className="text-xs text-gray-400">kg</span></p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500">산출면적</span>
|
||||
<p className="font-semibold">{location.area?.toFixed(1) || "-"} <span className="text-xs text-gray-400">m²</span></p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500">수량</span>
|
||||
<Input
|
||||
type="number"
|
||||
min="1"
|
||||
value={location.quantity}
|
||||
onChange={(e) => handleFieldChange("quantity", parseInt(e.target.value) || 1)}
|
||||
disabled={disabled}
|
||||
className="w-24 h-7 text-center font-semibold"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 필수 설정 (읽기 전용) */}
|
||||
<div className="bg-white px-4 py-3 border-b">
|
||||
<h4 className="text-sm font-semibold text-gray-700 mb-2 flex items-center gap-1">
|
||||
<Settings className="h-4 w-4" />
|
||||
필수 설정
|
||||
</h4>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div>
|
||||
<label className="text-xs text-gray-500 flex items-center gap-1 mb-1">
|
||||
🔧 가이드레일
|
||||
</label>
|
||||
<Badge variant="outline" className="bg-gray-50 text-gray-700 border-gray-300 px-3 py-1.5">
|
||||
{GUIDE_RAIL_TYPES.find(t => t.value === location.guideRailType)?.label || location.guideRailType}
|
||||
</Badge>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-gray-500 flex items-center gap-1 mb-1">
|
||||
⚡ 전원
|
||||
</label>
|
||||
<Badge variant="outline" className="bg-gray-50 text-gray-700 border-gray-300 px-3 py-1.5">
|
||||
{MOTOR_POWERS.find(p => p.value === location.motorPower)?.label || location.motorPower}
|
||||
</Badge>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-gray-500 flex items-center gap-1 mb-1">
|
||||
📦 제어기
|
||||
</label>
|
||||
<Badge variant="outline" className="bg-gray-50 text-gray-700 border-gray-300 px-3 py-1.5">
|
||||
{CONTROLLERS.find(c => c.value === location.controller)?.label || location.controller}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 탭 및 품목 테이블 */}
|
||||
<div className="flex-1 overflow-hidden flex flex-col">
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="flex flex-col h-full">
|
||||
{/* 탭 목록 - 스크롤 가능 */}
|
||||
<div className="border-b bg-white overflow-x-auto">
|
||||
<TabsList className="w-max min-w-full justify-start rounded-none bg-transparent h-auto p-0">
|
||||
{DETAIL_TABS.map((tab) => (
|
||||
<TabsTrigger
|
||||
key={tab.value}
|
||||
value={tab.value}
|
||||
className="rounded-none border-b-2 border-transparent data-[state=active]:border-blue-500 data-[state=active]:bg-blue-50 data-[state=active]:text-blue-700 px-4 py-2 text-sm whitespace-nowrap"
|
||||
>
|
||||
{tab.label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
{/* 본체 (스크린/슬랫) 탭 */}
|
||||
<TabsContent value="body" className="flex-1 overflow-auto m-0 p-0">
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-lg m-4">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-amber-100/50">
|
||||
<TableHead className="font-semibold">품목명</TableHead>
|
||||
<TableHead className="text-center font-semibold">제작사이즈</TableHead>
|
||||
<TableHead className="text-center font-semibold w-24">수량</TableHead>
|
||||
<TableHead className="text-center font-semibold w-20">작업</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{bomItemsByTab.body.map((item: any) => (
|
||||
<TableRow key={item.id} className="bg-white">
|
||||
<TableCell className="font-medium">{item.item_name}</TableCell>
|
||||
<TableCell className="text-center text-gray-600">{item.manufacture_size || "-"}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Input
|
||||
type="number"
|
||||
defaultValue={item.quantity}
|
||||
className="w-16 h-8 text-center"
|
||||
min={1}
|
||||
readOnly={disabled}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Button variant="ghost" size="sm" className="text-red-500 hover:text-red-700 hover:bg-red-50 p-1" disabled={disabled}>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
{/* 품목 추가 버튼 + 안내 */}
|
||||
<div className="p-3 flex items-center justify-between border-t border-amber-200">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="bg-blue-100 border-blue-300 text-blue-700 hover:bg-blue-200"
|
||||
onClick={() => setItemSearchOpen(true)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
품목 추가
|
||||
</Button>
|
||||
<span className="text-sm text-gray-500 flex items-center gap-1">
|
||||
💡 금액은 아래 견적금액요약에서 확인하세요
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* 절곡품 - 가이드레일, 케이스, 하단마감재 탭 */}
|
||||
{["guide-rail", "case", "bottom"].map((tabValue) => (
|
||||
<TabsContent key={tabValue} value={tabValue} className="flex-1 overflow-auto m-0 p-0">
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-lg m-4">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-amber-100/50">
|
||||
<TableHead className="font-semibold">품목명</TableHead>
|
||||
<TableHead className="text-center font-semibold">재질</TableHead>
|
||||
<TableHead className="text-center font-semibold">규격</TableHead>
|
||||
<TableHead className="text-center font-semibold w-28">납품길이</TableHead>
|
||||
<TableHead className="text-center font-semibold w-24">수량</TableHead>
|
||||
<TableHead className="text-center font-semibold w-20">작업</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{bomItemsByTab[tabValue]?.map((item: any) => (
|
||||
<TableRow key={item.id} className="bg-white">
|
||||
<TableCell className="font-medium">{item.item_name}</TableCell>
|
||||
<TableCell className="text-center text-gray-600">{item.material || "-"}</TableCell>
|
||||
<TableCell className="text-center text-gray-600">{item.spec || "-"}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Select defaultValue={item.delivery_length} disabled={disabled}>
|
||||
<SelectTrigger className="w-24 h-8">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{DELIVERY_LENGTH_OPTIONS.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Input
|
||||
type="number"
|
||||
defaultValue={item.quantity}
|
||||
className="w-16 h-8 text-center"
|
||||
min={1}
|
||||
readOnly={disabled}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Button variant="ghost" size="sm" className="text-red-500 hover:text-red-700 hover:bg-red-50 p-1" disabled={disabled}>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
{/* 품목 추가 버튼 + 안내 */}
|
||||
<div className="p-3 flex items-center justify-between border-t border-amber-200">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="bg-blue-100 border-blue-300 text-blue-700 hover:bg-blue-200"
|
||||
onClick={() => setItemSearchOpen(true)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
품목 추가
|
||||
</Button>
|
||||
<span className="text-sm text-gray-500 flex items-center gap-1">
|
||||
💡 금액은 아래 견적금액요약에서 확인하세요
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
))}
|
||||
|
||||
{/* 모터 & 제어기 탭 */}
|
||||
<TabsContent value="motor" className="flex-1 overflow-auto m-0 p-0">
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-lg m-4">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-amber-100/50">
|
||||
<TableHead className="font-semibold">품목명</TableHead>
|
||||
<TableHead className="text-center font-semibold">유형</TableHead>
|
||||
<TableHead className="text-center font-semibold">사양</TableHead>
|
||||
<TableHead className="text-center font-semibold w-24">수량</TableHead>
|
||||
<TableHead className="text-center font-semibold w-20">작업</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{bomItemsByTab.motor?.map((item: any) => (
|
||||
<TableRow key={item.id} className="bg-white">
|
||||
<TableCell className="font-medium">{item.item_name}</TableCell>
|
||||
<TableCell className="text-center text-gray-600">{item.type || "-"}</TableCell>
|
||||
<TableCell className="text-center text-gray-600">{item.spec || "-"}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Input
|
||||
type="number"
|
||||
defaultValue={item.quantity}
|
||||
className="w-16 h-8 text-center"
|
||||
min={1}
|
||||
readOnly={disabled}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Button variant="ghost" size="sm" className="text-red-500 hover:text-red-700 hover:bg-red-50 p-1" disabled={disabled}>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
{/* 품목 추가 버튼 + 안내 */}
|
||||
<div className="p-3 flex items-center justify-between border-t border-amber-200">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="bg-blue-100 border-blue-300 text-blue-700 hover:bg-blue-200"
|
||||
onClick={() => setItemSearchOpen(true)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
품목 추가
|
||||
</Button>
|
||||
<span className="text-sm text-gray-500 flex items-center gap-1">
|
||||
💡 금액은 아래 견적금액요약에서 확인하세요
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* 부자재 탭 */}
|
||||
<TabsContent value="accessory" className="flex-1 overflow-auto m-0 p-0">
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-lg m-4">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-amber-100/50">
|
||||
<TableHead className="font-semibold">품목명</TableHead>
|
||||
<TableHead className="text-center font-semibold">규격</TableHead>
|
||||
<TableHead className="text-center font-semibold w-28">납품길이</TableHead>
|
||||
<TableHead className="text-center font-semibold w-24">수량</TableHead>
|
||||
<TableHead className="text-center font-semibold w-20">작업</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{bomItemsByTab.accessory?.map((item: any) => (
|
||||
<TableRow key={item.id} className="bg-white">
|
||||
<TableCell className="font-medium">{item.item_name}</TableCell>
|
||||
<TableCell className="text-center text-gray-600">{item.spec || "-"}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Select defaultValue={item.delivery_length} disabled={disabled}>
|
||||
<SelectTrigger className="w-24 h-8">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{DELIVERY_LENGTH_OPTIONS.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Input
|
||||
type="number"
|
||||
defaultValue={item.quantity}
|
||||
className="w-16 h-8 text-center"
|
||||
min={1}
|
||||
readOnly={disabled}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Button variant="ghost" size="sm" className="text-red-500 hover:text-red-700 hover:bg-red-50 p-1" disabled={disabled}>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
{/* 품목 추가 버튼 + 안내 */}
|
||||
<div className="p-3 flex items-center justify-between border-t border-amber-200">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="bg-blue-100 border-blue-300 text-blue-700 hover:bg-blue-200"
|
||||
onClick={() => setItemSearchOpen(true)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
품목 추가
|
||||
</Button>
|
||||
<span className="text-sm text-gray-500 flex items-center gap-1">
|
||||
💡 금액은 아래 견적금액요약에서 확인하세요
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
{/* 금액 안내 */}
|
||||
{!location.bomResult && (
|
||||
<div className="bg-blue-50 px-4 py-2 border-t border-blue-200 text-center text-sm text-blue-700">
|
||||
💡 금액은 아래 견적금액요약에서 확인하세요
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 품목 검색 모달 */}
|
||||
<ItemSearchModal
|
||||
open={itemSearchOpen}
|
||||
onOpenChange={setItemSearchOpen}
|
||||
onSelectItem={(item) => {
|
||||
console.log(`[테스트] 품목 선택: ${item.code} - ${item.name} (탭: ${activeTab})`);
|
||||
}}
|
||||
tabLabel={DETAIL_TABS.find((t) => t.value === activeTab)?.label}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
548
src/components/quotes/LocationListPanel.tsx
Normal file
548
src/components/quotes/LocationListPanel.tsx
Normal file
@@ -0,0 +1,548 @@
|
||||
/**
|
||||
* 발주 개소 목록 패널
|
||||
*
|
||||
* - 개소 목록 테이블
|
||||
* - 품목 추가 폼
|
||||
* - 엑셀 업로드/다운로드
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
import { Plus, Upload, Download, Trash2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "../ui/button";
|
||||
import { Input } from "../ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "../ui/select";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "../ui/table";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "../ui/alert-dialog";
|
||||
|
||||
import type { LocationItem } from "./QuoteRegistrationV2";
|
||||
import type { FinishedGoods } from "./actions";
|
||||
import * as XLSX from "xlsx";
|
||||
|
||||
// =============================================================================
|
||||
// 상수
|
||||
// =============================================================================
|
||||
|
||||
// 가이드레일 설치 유형
|
||||
const GUIDE_RAIL_TYPES = [
|
||||
{ value: "wall", label: "벽면형" },
|
||||
{ value: "floor", label: "측면형" },
|
||||
];
|
||||
|
||||
// 모터 전원
|
||||
const MOTOR_POWERS = [
|
||||
{ value: "single", label: "단상(220V)" },
|
||||
{ value: "three", label: "삼상(380V)" },
|
||||
];
|
||||
|
||||
// 연동제어기
|
||||
const CONTROLLERS = [
|
||||
{ value: "basic", label: "단독" },
|
||||
{ value: "smart", label: "연동" },
|
||||
{ value: "premium", label: "매립형-뒷박스포함" },
|
||||
];
|
||||
|
||||
// =============================================================================
|
||||
// Props
|
||||
// =============================================================================
|
||||
|
||||
interface LocationListPanelProps {
|
||||
locations: LocationItem[];
|
||||
selectedLocationId: string | null;
|
||||
onSelectLocation: (id: string) => void;
|
||||
onAddLocation: (location: Omit<LocationItem, "id">) => void;
|
||||
onDeleteLocation: (id: string) => void;
|
||||
onExcelUpload: (locations: Omit<LocationItem, "id">[]) => void;
|
||||
finishedGoods: FinishedGoods[];
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 컴포넌트
|
||||
// =============================================================================
|
||||
|
||||
export function LocationListPanel({
|
||||
locations,
|
||||
selectedLocationId,
|
||||
onSelectLocation,
|
||||
onAddLocation,
|
||||
onDeleteLocation,
|
||||
onExcelUpload,
|
||||
finishedGoods,
|
||||
disabled = false,
|
||||
}: LocationListPanelProps) {
|
||||
// ---------------------------------------------------------------------------
|
||||
// 상태
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// 추가 폼 상태
|
||||
const [formData, setFormData] = useState({
|
||||
floor: "",
|
||||
code: "",
|
||||
openWidth: "",
|
||||
openHeight: "",
|
||||
productCode: "",
|
||||
quantity: "1",
|
||||
guideRailType: "wall",
|
||||
motorPower: "single",
|
||||
controller: "basic",
|
||||
});
|
||||
|
||||
// 삭제 확인 다이얼로그
|
||||
const [deleteTarget, setDeleteTarget] = useState<string | null>(null);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 핸들러
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// 폼 필드 변경
|
||||
const handleFormChange = useCallback((field: string, value: string) => {
|
||||
setFormData((prev) => ({ ...prev, [field]: value }));
|
||||
}, []);
|
||||
|
||||
// 개소 추가
|
||||
const handleAdd = useCallback(() => {
|
||||
// 유효성 검사
|
||||
if (!formData.floor || !formData.code) {
|
||||
toast.error("층과 부호를 입력해주세요.");
|
||||
return;
|
||||
}
|
||||
if (!formData.openWidth || !formData.openHeight) {
|
||||
toast.error("가로와 세로를 입력해주세요.");
|
||||
return;
|
||||
}
|
||||
if (!formData.productCode) {
|
||||
toast.error("제품을 선택해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
const product = finishedGoods.find((fg) => fg.item_code === formData.productCode);
|
||||
|
||||
const newLocation: Omit<LocationItem, "id"> = {
|
||||
floor: formData.floor,
|
||||
code: formData.code,
|
||||
openWidth: parseFloat(formData.openWidth) || 0,
|
||||
openHeight: parseFloat(formData.openHeight) || 0,
|
||||
productCode: formData.productCode,
|
||||
productName: product?.item_name || formData.productCode,
|
||||
quantity: parseInt(formData.quantity) || 1,
|
||||
guideRailType: formData.guideRailType,
|
||||
motorPower: formData.motorPower,
|
||||
controller: formData.controller,
|
||||
wingSize: 50,
|
||||
inspectionFee: 50000,
|
||||
};
|
||||
|
||||
onAddLocation(newLocation);
|
||||
|
||||
// 폼 초기화 (일부 필드 유지)
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
floor: "",
|
||||
code: "",
|
||||
openWidth: "",
|
||||
openHeight: "",
|
||||
quantity: "1",
|
||||
}));
|
||||
}, [formData, finishedGoods, onAddLocation]);
|
||||
|
||||
// 엑셀 양식 다운로드
|
||||
const handleDownloadTemplate = useCallback(() => {
|
||||
const templateData = [
|
||||
{
|
||||
층: "1층",
|
||||
부호: "FSS-01",
|
||||
가로: 5000,
|
||||
세로: 3000,
|
||||
제품코드: "KSS01",
|
||||
수량: 1,
|
||||
가이드레일: "wall",
|
||||
전원: "single",
|
||||
제어기: "basic",
|
||||
},
|
||||
];
|
||||
|
||||
const ws = XLSX.utils.json_to_sheet(templateData);
|
||||
const wb = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(wb, ws, "개소목록");
|
||||
|
||||
// 컬럼 너비 설정
|
||||
ws["!cols"] = [
|
||||
{ wch: 10 }, // 층
|
||||
{ wch: 12 }, // 부호
|
||||
{ wch: 10 }, // 가로
|
||||
{ wch: 10 }, // 세로
|
||||
{ wch: 15 }, // 제품코드
|
||||
{ wch: 8 }, // 수량
|
||||
{ wch: 12 }, // 가이드레일
|
||||
{ wch: 12 }, // 전원
|
||||
{ wch: 12 }, // 제어기
|
||||
];
|
||||
|
||||
XLSX.writeFile(wb, "견적_개소목록_양식.xlsx");
|
||||
toast.success("엑셀 양식이 다운로드되었습니다.");
|
||||
}, []);
|
||||
|
||||
// 엑셀 업로드
|
||||
const handleFileUpload = useCallback(
|
||||
(event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
try {
|
||||
const data = new Uint8Array(e.target?.result as ArrayBuffer);
|
||||
const workbook = XLSX.read(data, { type: "array" });
|
||||
const sheetName = workbook.SheetNames[0];
|
||||
const worksheet = workbook.Sheets[sheetName];
|
||||
const jsonData = XLSX.utils.sheet_to_json(worksheet);
|
||||
|
||||
const parsedLocations: Omit<LocationItem, "id">[] = jsonData.map((row: any) => {
|
||||
const productCode = row["제품코드"] || "";
|
||||
const product = finishedGoods.find((fg) => fg.item_code === productCode);
|
||||
|
||||
return {
|
||||
floor: String(row["층"] || ""),
|
||||
code: String(row["부호"] || ""),
|
||||
openWidth: parseFloat(row["가로"]) || 0,
|
||||
openHeight: parseFloat(row["세로"]) || 0,
|
||||
productCode: productCode,
|
||||
productName: product?.item_name || productCode,
|
||||
quantity: parseInt(row["수량"]) || 1,
|
||||
guideRailType: row["가이드레일"] || "wall",
|
||||
motorPower: row["전원"] || "single",
|
||||
controller: row["제어기"] || "basic",
|
||||
wingSize: 50,
|
||||
inspectionFee: 50000,
|
||||
};
|
||||
});
|
||||
|
||||
// 유효한 데이터만 필터링
|
||||
const validLocations = parsedLocations.filter(
|
||||
(loc) => loc.floor && loc.code && loc.openWidth > 0 && loc.openHeight > 0
|
||||
);
|
||||
|
||||
if (validLocations.length === 0) {
|
||||
toast.error("유효한 데이터가 없습니다. 양식을 확인해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
onExcelUpload(validLocations);
|
||||
} catch (error) {
|
||||
console.error("엑셀 파싱 오류:", error);
|
||||
toast.error("엑셀 파일을 읽는 중 오류가 발생했습니다.");
|
||||
}
|
||||
};
|
||||
reader.readAsArrayBuffer(file);
|
||||
|
||||
// 파일 입력 초기화
|
||||
event.target.value = "";
|
||||
},
|
||||
[finishedGoods, onExcelUpload]
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 렌더링
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
return (
|
||||
<div className="border-r border-gray-200 flex flex-col">
|
||||
{/* 헤더 */}
|
||||
<div className="bg-blue-100 px-4 py-3 border-b border-blue-200">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="font-semibold text-blue-800">
|
||||
📋 발주 개소 목록 ({locations.length})
|
||||
</h3>
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleDownloadTemplate}
|
||||
disabled={disabled}
|
||||
className="text-xs"
|
||||
>
|
||||
<Download className="h-3 w-3 mr-1" />
|
||||
양식
|
||||
</Button>
|
||||
<label>
|
||||
<input
|
||||
type="file"
|
||||
accept=".xlsx,.xls"
|
||||
onChange={handleFileUpload}
|
||||
disabled={disabled}
|
||||
className="hidden"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={disabled}
|
||||
className="text-xs"
|
||||
asChild
|
||||
>
|
||||
<span>
|
||||
<Upload className="h-3 w-3 mr-1" />
|
||||
업로드
|
||||
</span>
|
||||
</Button>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 개소 목록 테이블 */}
|
||||
<div className="flex-1 overflow-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-gray-50">
|
||||
<TableHead className="w-[60px] text-center">층</TableHead>
|
||||
<TableHead className="w-[80px] text-center">부호</TableHead>
|
||||
<TableHead className="w-[100px] text-center">사이즈</TableHead>
|
||||
<TableHead className="w-[80px] text-center">제품</TableHead>
|
||||
<TableHead className="w-[50px] text-center">수량</TableHead>
|
||||
<TableHead className="w-[40px]"></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{locations.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="text-center text-gray-500 py-8">
|
||||
개소를 추가해주세요
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
locations.map((loc) => (
|
||||
<TableRow
|
||||
key={loc.id}
|
||||
className={`cursor-pointer hover:bg-blue-50 ${
|
||||
selectedLocationId === loc.id ? "bg-blue-100" : ""
|
||||
}`}
|
||||
onClick={() => onSelectLocation(loc.id)}
|
||||
>
|
||||
<TableCell className="text-center font-medium">{loc.floor}</TableCell>
|
||||
<TableCell className="text-center">{loc.code}</TableCell>
|
||||
<TableCell className="text-center text-sm">
|
||||
{loc.openWidth}×{loc.openHeight}
|
||||
</TableCell>
|
||||
<TableCell className="text-center text-sm">{loc.productCode}</TableCell>
|
||||
<TableCell className="text-center">{loc.quantity}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{!disabled && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 text-red-500 hover:text-red-600"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setDeleteTarget(loc.id);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* 추가 폼 */}
|
||||
{!disabled && (
|
||||
<div className="border-t border-blue-200 bg-blue-50 p-4 space-y-3">
|
||||
{/* 1행: 층, 부호, 가로, 세로, 제품명, 수량 */}
|
||||
<div className="grid grid-cols-6 gap-2">
|
||||
<div>
|
||||
<label className="text-xs text-gray-600">층</label>
|
||||
<Input
|
||||
placeholder="1층"
|
||||
value={formData.floor}
|
||||
onChange={(e) => handleFormChange("floor", e.target.value)}
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-gray-600">부호</label>
|
||||
<Input
|
||||
placeholder="FSS-01"
|
||||
value={formData.code}
|
||||
onChange={(e) => handleFormChange("code", e.target.value)}
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-gray-600">가로</label>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="5000"
|
||||
value={formData.openWidth}
|
||||
onChange={(e) => handleFormChange("openWidth", e.target.value)}
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-gray-600">세로</label>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="3000"
|
||||
value={formData.openHeight}
|
||||
onChange={(e) => handleFormChange("openHeight", e.target.value)}
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-gray-600">제품명</label>
|
||||
<Select
|
||||
value={formData.productCode}
|
||||
onValueChange={(value) => handleFormChange("productCode", value)}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-sm">
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{finishedGoods.map((fg) => (
|
||||
<SelectItem key={fg.item_code} value={fg.item_code}>
|
||||
{fg.item_code}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-gray-600">수량</label>
|
||||
<Input
|
||||
type="number"
|
||||
min="1"
|
||||
value={formData.quantity}
|
||||
onChange={(e) => handleFormChange("quantity", e.target.value)}
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 2행: 가이드레일, 전원, 제어기, 버튼 */}
|
||||
<div className="flex items-end gap-2">
|
||||
<div className="flex-1">
|
||||
<label className="text-xs text-gray-600 flex items-center gap-1">
|
||||
🔧 가이드레일
|
||||
</label>
|
||||
<Select
|
||||
value={formData.guideRailType}
|
||||
onValueChange={(value) => handleFormChange("guideRailType", value)}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{GUIDE_RAIL_TYPES.map((type) => (
|
||||
<SelectItem key={type.value} value={type.value}>
|
||||
{type.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<label className="text-xs text-gray-600 flex items-center gap-1">
|
||||
⚡ 전원
|
||||
</label>
|
||||
<Select
|
||||
value={formData.motorPower}
|
||||
onValueChange={(value) => handleFormChange("motorPower", value)}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{MOTOR_POWERS.map((power) => (
|
||||
<SelectItem key={power.value} value={power.value}>
|
||||
{power.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<label className="text-xs text-gray-600 flex items-center gap-1">
|
||||
📦 제어기
|
||||
</label>
|
||||
<Select
|
||||
value={formData.controller}
|
||||
onValueChange={(value) => handleFormChange("controller", value)}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{CONTROLLERS.map((ctrl) => (
|
||||
<SelectItem key={ctrl.value} value={ctrl.value}>
|
||||
{ctrl.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleAdd}
|
||||
className="h-8 bg-green-500 hover:bg-green-600"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 삭제 확인 다이얼로그 */}
|
||||
<AlertDialog open={!!deleteTarget} onOpenChange={() => setDeleteTarget(null)}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>개소 삭제</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
선택한 개소를 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>취소</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => {
|
||||
if (deleteTarget) {
|
||||
onDeleteLocation(deleteTarget);
|
||||
setDeleteTarget(null);
|
||||
}
|
||||
}}
|
||||
className="bg-red-500 hover:bg-red-600"
|
||||
>
|
||||
삭제
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
136
src/components/quotes/QuoteFooterBar.tsx
Normal file
136
src/components/quotes/QuoteFooterBar.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* 견적 푸터 바
|
||||
*
|
||||
* - 예상 전체 견적금액 표시
|
||||
* - 버튼: 견적서 산출, 임시저장, 최종저장
|
||||
* - 뒤로가기 버튼
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import { Download, Save, Check, ArrowLeft, Loader2, Calculator, Eye } from "lucide-react";
|
||||
|
||||
import { Button } from "../ui/button";
|
||||
|
||||
// =============================================================================
|
||||
// Props
|
||||
// =============================================================================
|
||||
|
||||
interface QuoteFooterBarProps {
|
||||
totalLocations: number;
|
||||
totalAmount: number;
|
||||
status: "draft" | "temporary" | "final";
|
||||
onCalculate: () => void;
|
||||
onPreview: () => void;
|
||||
onSaveTemporary: () => void;
|
||||
onSaveFinal: () => void;
|
||||
onBack: () => void;
|
||||
isCalculating?: boolean;
|
||||
isSaving?: boolean;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 컴포넌트
|
||||
// =============================================================================
|
||||
|
||||
export function QuoteFooterBar({
|
||||
totalLocations,
|
||||
totalAmount,
|
||||
status,
|
||||
onCalculate,
|
||||
onPreview,
|
||||
onSaveTemporary,
|
||||
onSaveFinal,
|
||||
onBack,
|
||||
isCalculating = false,
|
||||
isSaving = false,
|
||||
disabled = false,
|
||||
}: QuoteFooterBarProps) {
|
||||
return (
|
||||
<div className="sticky bottom-0 bg-gradient-to-r from-blue-50 to-indigo-50 border-t border-blue-200 shadow-lg">
|
||||
<div className="px-6 py-4 flex items-center justify-between">
|
||||
{/* 왼쪽: 뒤로가기 + 금액 표시 */}
|
||||
<div className="flex items-center gap-6">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onBack}
|
||||
className="gap-2"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
목록으로
|
||||
</Button>
|
||||
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">예상 전체 견적금액</p>
|
||||
<p className="text-3xl font-bold text-blue-600">
|
||||
{totalAmount.toLocaleString()}
|
||||
<span className="text-lg font-normal text-gray-500 ml-1">원</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 오른쪽: 버튼들 */}
|
||||
<div className="flex items-center gap-3">
|
||||
{/* 견적서 산출 */}
|
||||
<Button
|
||||
onClick={onCalculate}
|
||||
disabled={disabled || isCalculating || totalLocations === 0}
|
||||
className="bg-indigo-600 hover:bg-indigo-700 text-white gap-2 px-6"
|
||||
>
|
||||
{isCalculating ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
산출 중...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Calculator className="h-4 w-4" />
|
||||
견적서 산출
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{/* 미리보기 */}
|
||||
<Button
|
||||
onClick={onPreview}
|
||||
disabled={disabled || totalLocations === 0}
|
||||
variant="outline"
|
||||
className="gap-2 px-6"
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
미리보기
|
||||
</Button>
|
||||
|
||||
{/* 임시저장 */}
|
||||
<Button
|
||||
onClick={onSaveTemporary}
|
||||
disabled={disabled || isSaving}
|
||||
className="bg-slate-500 hover:bg-slate-600 text-white gap-2 px-6"
|
||||
>
|
||||
{isSaving ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Save className="h-4 w-4" />
|
||||
)}
|
||||
임시저장
|
||||
</Button>
|
||||
|
||||
{/* 최종저장 */}
|
||||
<Button
|
||||
onClick={onSaveFinal}
|
||||
disabled={disabled || isSaving || totalAmount === 0}
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white gap-2 px-6"
|
||||
>
|
||||
{isSaving ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Check className="h-4 w-4" />
|
||||
)}
|
||||
최종저장
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
303
src/components/quotes/QuotePreviewModal.tsx
Normal file
303
src/components/quotes/QuotePreviewModal.tsx
Normal file
@@ -0,0 +1,303 @@
|
||||
/**
|
||||
* 견적서 미리보기 모달
|
||||
*
|
||||
* - 견적서 문서 형식으로 미리보기
|
||||
* - PDF, 이메일 전송 버튼
|
||||
* - 인쇄 기능
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import { Download, Mail, Printer, X as XIcon } from "lucide-react";
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
VisuallyHidden,
|
||||
} from "../ui/dialog";
|
||||
import { Button } from "../ui/button";
|
||||
|
||||
import type { QuoteFormDataV2 } from "./QuoteRegistrationV2";
|
||||
|
||||
// =============================================================================
|
||||
// Props
|
||||
// =============================================================================
|
||||
|
||||
interface QuotePreviewModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
quoteData: QuoteFormDataV2 | null;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 컴포넌트
|
||||
// =============================================================================
|
||||
|
||||
export function QuotePreviewModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
quoteData,
|
||||
}: QuotePreviewModalProps) {
|
||||
if (!quoteData) return null;
|
||||
|
||||
// 총 금액 계산
|
||||
const totalAmount = quoteData.locations.reduce(
|
||||
(sum, loc) => sum + (loc.totalPrice || 0),
|
||||
0
|
||||
);
|
||||
|
||||
// 부가세
|
||||
const vat = Math.round(totalAmount * 0.1);
|
||||
const grandTotal = totalAmount + vat;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-[95vw] md:max-w-[800px] lg:max-w-[900px] h-[95vh] p-0 flex flex-col gap-0 overflow-hidden [&>button]:hidden">
|
||||
<VisuallyHidden>
|
||||
<DialogTitle>견적서 상세</DialogTitle>
|
||||
</VisuallyHidden>
|
||||
|
||||
{/* 헤더 영역 - 제목 + 닫기 버튼 */}
|
||||
<div className="flex-shrink-0 flex items-center justify-between px-6 py-4 border-b bg-white">
|
||||
<h2 className="text-lg font-semibold">견적서</h2>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => onOpenChange(false)}
|
||||
className="h-8 w-8"
|
||||
>
|
||||
<XIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 버튼 영역 - PDF, 이메일, 인쇄 */}
|
||||
<div className="flex-shrink-0 flex items-center gap-2 px-6 py-3 bg-muted/30 border-b">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="bg-red-500 hover:bg-red-600 text-white border-red-500"
|
||||
onClick={() => console.log("[테스트] PDF 다운로드")}
|
||||
>
|
||||
<Download className="h-4 w-4 mr-1" />
|
||||
PDF
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="bg-yellow-500 hover:bg-yellow-600 text-white border-yellow-500"
|
||||
onClick={() => console.log("[테스트] 이메일 전송")}
|
||||
>
|
||||
<Mail className="h-4 w-4 mr-1" />
|
||||
이메일
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => console.log("[테스트] 인쇄")}
|
||||
>
|
||||
<Printer className="h-4 w-4 mr-1" />
|
||||
인쇄
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 문서 영역 - 스크롤 */}
|
||||
<div className="flex-1 overflow-y-auto bg-gray-100 p-6">
|
||||
<div className="max-w-[210mm] mx-auto bg-white shadow-lg rounded-lg p-8">
|
||||
{/* 제목 */}
|
||||
<div className="text-center mb-6">
|
||||
<h1 className="text-3xl font-bold tracking-widest">견 적 서</h1>
|
||||
<p className="text-sm text-gray-500 mt-2">
|
||||
문서번호: {quoteData.id || "-"} | 작성일자: {quoteData.registrationDate || "-"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 수요자 정보 */}
|
||||
<div className="border border-gray-300 mb-4">
|
||||
<div className="bg-gray-100 px-3 py-2 font-semibold border-b border-gray-300">
|
||||
수 요 자
|
||||
</div>
|
||||
<div className="p-3 grid grid-cols-2 gap-2 text-sm">
|
||||
<div className="flex">
|
||||
<span className="w-20 text-gray-600">업체명</span>
|
||||
<span className="font-medium">{quoteData.clientName || "-"}</span>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<span className="w-20 text-gray-600">담당자</span>
|
||||
<span className="font-medium">{quoteData.manager || "-"}</span>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<span className="w-20 text-gray-600">프로젝트명</span>
|
||||
<span className="font-medium">{quoteData.siteName || "-"}</span>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<span className="w-20 text-gray-600">연락처</span>
|
||||
<span className="font-medium">{quoteData.contact || "-"}</span>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<span className="w-20 text-gray-600">견적일자</span>
|
||||
<span className="font-medium">{quoteData.registrationDate || "-"}</span>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<span className="w-20 text-gray-600">유효기간</span>
|
||||
<span className="font-medium">{quoteData.dueDate || "-"}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 공급자 정보 */}
|
||||
<div className="border border-gray-300 mb-6">
|
||||
<div className="bg-gray-100 px-3 py-2 font-semibold border-b border-gray-300">
|
||||
공 급 자
|
||||
</div>
|
||||
<div className="p-3 grid grid-cols-2 gap-2 text-sm">
|
||||
<div className="flex">
|
||||
<span className="w-24 text-gray-600">상호</span>
|
||||
<span className="font-medium">프론트_테스트회사</span>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<span className="w-24 text-gray-600">사업자등록번호</span>
|
||||
<span className="font-medium">123-45-67890</span>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<span className="w-24 text-gray-600">대표자</span>
|
||||
<span className="font-medium">프론트</span>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<span className="w-24 text-gray-600">업태</span>
|
||||
<span className="font-medium">업태명</span>
|
||||
</div>
|
||||
<div className="flex col-span-2">
|
||||
<span className="w-24 text-gray-600">종목</span>
|
||||
<span className="font-medium">김종명</span>
|
||||
</div>
|
||||
<div className="flex col-span-2">
|
||||
<span className="w-24 text-gray-600">사업장주소</span>
|
||||
<span className="font-medium">07547 서울 강서구 양천로 583 B-1602</span>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<span className="w-24 text-gray-600">전화</span>
|
||||
<span className="font-medium">01048209104</span>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<span className="w-24 text-gray-600">이메일</span>
|
||||
<span className="font-medium">codebridgex@codebridge-x.com</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 총 견적금액 */}
|
||||
<div className="border-2 border-gray-800 p-4 mb-6 text-center">
|
||||
<p className="text-sm text-gray-600 mb-1">총 견적금액</p>
|
||||
<p className="text-3xl font-bold">
|
||||
₩ {grandTotal.toLocaleString()}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 mt-1">※ 부가가치세 포함</p>
|
||||
</div>
|
||||
|
||||
{/* 제품 구성정보 */}
|
||||
<div className="border border-gray-300 mb-6">
|
||||
<div className="bg-gray-800 text-white px-3 py-2 font-semibold">
|
||||
제 품 구 성 정 보
|
||||
</div>
|
||||
<div className="p-3 grid grid-cols-2 gap-2 text-sm">
|
||||
<div className="flex">
|
||||
<span className="w-20 text-gray-600">모델</span>
|
||||
<span className="font-medium">
|
||||
{quoteData.locations[0]?.productCode || "-"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<span className="w-20 text-gray-600">총 수량</span>
|
||||
<span className="font-medium">{quoteData.locations.length}개소</span>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<span className="w-20 text-gray-600">오픈사이즈</span>
|
||||
<span className="font-medium">
|
||||
{quoteData.locations[0]?.openWidth || "-"} × {quoteData.locations[0]?.openHeight || "-"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<span className="w-20 text-gray-600">설치유형</span>
|
||||
<span className="font-medium">-</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 품목 내역 */}
|
||||
<div className="border border-gray-300 mb-6">
|
||||
<div className="bg-gray-800 text-white px-3 py-2 font-semibold">
|
||||
품 목 내 역
|
||||
</div>
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="bg-gray-100 border-b border-gray-300">
|
||||
<th className="px-3 py-2 text-left">No.</th>
|
||||
<th className="px-3 py-2 text-left">품목명</th>
|
||||
<th className="px-3 py-2 text-center">규격</th>
|
||||
<th className="px-3 py-2 text-center">수량</th>
|
||||
<th className="px-3 py-2 text-center">단위</th>
|
||||
<th className="px-3 py-2 text-right">단가</th>
|
||||
<th className="px-3 py-2 text-right">금액</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{quoteData.locations.map((loc, index) => (
|
||||
<tr key={loc.id} className="border-b border-gray-200">
|
||||
<td className="px-3 py-2">{index + 1}</td>
|
||||
<td className="px-3 py-2">{loc.productCode}</td>
|
||||
<td className="px-3 py-2 text-center">
|
||||
{loc.openWidth}×{loc.openHeight}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-center">{loc.quantity}</td>
|
||||
<td className="px-3 py-2 text-center">EA</td>
|
||||
<td className="px-3 py-2 text-right">
|
||||
{(loc.unitPrice || 0).toLocaleString()}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right">
|
||||
{(loc.totalPrice || 0).toLocaleString()}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr className="border-t-2 border-gray-400">
|
||||
<td colSpan={5}></td>
|
||||
<td className="px-3 py-2 text-right font-medium">공급가액 합계</td>
|
||||
<td className="px-3 py-2 text-right font-bold">
|
||||
{totalAmount.toLocaleString()}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colSpan={5}></td>
|
||||
<td className="px-3 py-2 text-right font-medium">부가가치세 (10%)</td>
|
||||
<td className="px-3 py-2 text-right font-bold">
|
||||
{vat.toLocaleString()}
|
||||
</td>
|
||||
</tr>
|
||||
<tr className="bg-gray-100">
|
||||
<td colSpan={5}></td>
|
||||
<td className="px-3 py-2 text-right font-medium">총 견적금액</td>
|
||||
<td className="px-3 py-2 text-right font-bold text-lg">
|
||||
{grandTotal.toLocaleString()}
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* 비고사항 */}
|
||||
<div className="border border-gray-300">
|
||||
<div className="bg-gray-800 text-white px-3 py-2 font-semibold">
|
||||
비 고 사 항
|
||||
</div>
|
||||
<div className="p-3 min-h-[80px] text-sm text-gray-600">
|
||||
{quoteData.remarks || "비고 테스트"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
603
src/components/quotes/QuoteRegistrationV2.tsx
Normal file
603
src/components/quotes/QuoteRegistrationV2.tsx
Normal file
@@ -0,0 +1,603 @@
|
||||
/**
|
||||
* 견적 등록/수정 컴포넌트 V2
|
||||
*
|
||||
* 새로운 레이아웃:
|
||||
* - 좌우 분할: 발주 개소 목록 | 선택 개소 상세
|
||||
* - 하단: 견적 금액 요약 (개소별 + 상세별)
|
||||
* - 푸터: 총 금액 + 버튼들 (견적서 산출, 임시저장, 최종저장)
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useMemo, useCallback } from "react";
|
||||
import { FileText, Calculator, Download, Save, Check } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "../ui/card";
|
||||
import { Button } from "../ui/button";
|
||||
import { Badge } from "../ui/badge";
|
||||
import { Input } from "../ui/input";
|
||||
import { Textarea } from "../ui/textarea";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "../ui/select";
|
||||
import {
|
||||
ResponsiveFormTemplate,
|
||||
FormSection,
|
||||
FormFieldGrid,
|
||||
} from "../templates/ResponsiveFormTemplate";
|
||||
import { FormField } from "../molecules/FormField";
|
||||
|
||||
import { LocationListPanel } from "./LocationListPanel";
|
||||
import { LocationDetailPanel } from "./LocationDetailPanel";
|
||||
import { QuoteSummaryPanel } from "./QuoteSummaryPanel";
|
||||
import { QuoteFooterBar } from "./QuoteFooterBar";
|
||||
import { QuotePreviewModal } from "./QuotePreviewModal";
|
||||
|
||||
import {
|
||||
getFinishedGoods,
|
||||
calculateBomBulk,
|
||||
getSiteNames,
|
||||
type FinishedGoods,
|
||||
type BomCalculationResult,
|
||||
} from "./actions";
|
||||
import { getClients } from "../accounting/VendorManagement/actions";
|
||||
import { isNextRedirectError } from "@/lib/utils/redirect-error";
|
||||
import type { Vendor } from "../accounting/VendorManagement";
|
||||
import type { BomMaterial, CalculationResults } from "./types";
|
||||
|
||||
// =============================================================================
|
||||
// 타입 정의
|
||||
// =============================================================================
|
||||
|
||||
// 발주 개소 항목
|
||||
export interface LocationItem {
|
||||
id: string;
|
||||
floor: string; // 층
|
||||
code: string; // 부호
|
||||
openWidth: number; // 가로 (오픈사이즈 W)
|
||||
openHeight: number; // 세로 (오픈사이즈 H)
|
||||
productCode: string; // 제품코드
|
||||
productName: string; // 제품명
|
||||
quantity: number; // 수량
|
||||
guideRailType: string; // 가이드레일 설치 유형
|
||||
motorPower: string; // 모터 전원
|
||||
controller: string; // 연동제어기
|
||||
wingSize: number; // 마구리 날개치수
|
||||
inspectionFee: number; // 검사비
|
||||
// 계산 결과
|
||||
manufactureWidth?: number; // 제작사이즈 W
|
||||
manufactureHeight?: number; // 제작사이즈 H
|
||||
weight?: number; // 산출중량 (kg)
|
||||
area?: number; // 산출면적 (m²)
|
||||
unitPrice?: number; // 단가
|
||||
totalPrice?: number; // 합계
|
||||
bomResult?: BomCalculationResult; // BOM 계산 결과
|
||||
}
|
||||
|
||||
// 견적 폼 데이터 V2
|
||||
export interface QuoteFormDataV2 {
|
||||
id?: string;
|
||||
registrationDate: string;
|
||||
writer: string;
|
||||
clientId: string;
|
||||
clientName: string;
|
||||
siteName: string;
|
||||
manager: string;
|
||||
contact: string;
|
||||
dueDate: string;
|
||||
remarks: string;
|
||||
status: "draft" | "temporary" | "final"; // 작성중, 임시저장, 최종저장
|
||||
locations: LocationItem[];
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 상수
|
||||
// =============================================================================
|
||||
|
||||
// 초기 개소 항목
|
||||
const createNewLocation = (): LocationItem => ({
|
||||
id: `loc-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||
floor: "",
|
||||
code: "",
|
||||
openWidth: 0,
|
||||
openHeight: 0,
|
||||
productCode: "",
|
||||
productName: "",
|
||||
quantity: 1,
|
||||
guideRailType: "wall",
|
||||
motorPower: "single",
|
||||
controller: "basic",
|
||||
wingSize: 50,
|
||||
inspectionFee: 50000,
|
||||
});
|
||||
|
||||
// 초기 폼 데이터
|
||||
const INITIAL_FORM_DATA: QuoteFormDataV2 = {
|
||||
registrationDate: new Date().toISOString().split("T")[0],
|
||||
writer: "드미트리", // TODO: 로그인 사용자 정보
|
||||
clientId: "",
|
||||
clientName: "",
|
||||
siteName: "",
|
||||
manager: "",
|
||||
contact: "",
|
||||
dueDate: "",
|
||||
remarks: "",
|
||||
status: "draft",
|
||||
locations: [],
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// Props
|
||||
// =============================================================================
|
||||
|
||||
interface QuoteRegistrationV2Props {
|
||||
mode: "create" | "view" | "edit";
|
||||
onBack: () => void;
|
||||
onSave?: (data: QuoteFormDataV2, saveType: "temporary" | "final") => Promise<void>;
|
||||
onCalculate?: () => void;
|
||||
initialData?: QuoteFormDataV2 | null;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 메인 컴포넌트
|
||||
// =============================================================================
|
||||
|
||||
export function QuoteRegistrationV2({
|
||||
mode,
|
||||
onBack,
|
||||
onSave,
|
||||
onCalculate,
|
||||
initialData,
|
||||
isLoading = false,
|
||||
}: QuoteRegistrationV2Props) {
|
||||
// ---------------------------------------------------------------------------
|
||||
// 상태
|
||||
// ---------------------------------------------------------------------------
|
||||
const [formData, setFormData] = useState<QuoteFormDataV2>(
|
||||
initialData || INITIAL_FORM_DATA
|
||||
);
|
||||
const [selectedLocationId, setSelectedLocationId] = useState<string | null>(null);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [isCalculating, setIsCalculating] = useState(false);
|
||||
const [previewModalOpen, setPreviewModalOpen] = useState(false);
|
||||
|
||||
// API 데이터
|
||||
const [clients, setClients] = useState<Vendor[]>([]);
|
||||
const [finishedGoods, setFinishedGoods] = useState<FinishedGoods[]>([]);
|
||||
const [siteNames, setSiteNames] = useState<string[]>([]);
|
||||
const [isLoadingClients, setIsLoadingClients] = useState(false);
|
||||
const [isLoadingProducts, setIsLoadingProducts] = useState(false);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 계산된 값
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// 선택된 개소
|
||||
const selectedLocation = useMemo(() => {
|
||||
return formData.locations.find((loc) => loc.id === selectedLocationId) || null;
|
||||
}, [formData.locations, selectedLocationId]);
|
||||
|
||||
// 총 금액
|
||||
const totalAmount = useMemo(() => {
|
||||
return formData.locations.reduce((sum, loc) => sum + (loc.totalPrice || 0), 0);
|
||||
}, [formData.locations]);
|
||||
|
||||
// 개소별 합계
|
||||
const locationTotals = useMemo(() => {
|
||||
return formData.locations.map((loc) => ({
|
||||
id: loc.id,
|
||||
label: `${loc.floor} / ${loc.code}`,
|
||||
productCode: loc.productCode,
|
||||
quantity: loc.quantity,
|
||||
unitPrice: loc.unitPrice || 0,
|
||||
totalPrice: loc.totalPrice || 0,
|
||||
}));
|
||||
}, [formData.locations]);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 초기 데이터 로드
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
useEffect(() => {
|
||||
const loadInitialData = async () => {
|
||||
// 거래처 로드
|
||||
setIsLoadingClients(true);
|
||||
try {
|
||||
const result = await getClients();
|
||||
if (result.success) {
|
||||
setClients(result.data);
|
||||
}
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error("거래처 로드 실패:", error);
|
||||
} finally {
|
||||
setIsLoadingClients(false);
|
||||
}
|
||||
|
||||
// 완제품 로드
|
||||
setIsLoadingProducts(true);
|
||||
try {
|
||||
const result = await getFinishedGoods();
|
||||
if (result.success) {
|
||||
setFinishedGoods(result.data);
|
||||
}
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error("완제품 로드 실패:", error);
|
||||
} finally {
|
||||
setIsLoadingProducts(false);
|
||||
}
|
||||
|
||||
// 현장명 로드
|
||||
try {
|
||||
const result = await getSiteNames();
|
||||
if (result.success) {
|
||||
setSiteNames(result.data);
|
||||
}
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
}
|
||||
};
|
||||
|
||||
loadInitialData();
|
||||
}, []);
|
||||
|
||||
// initialData 변경 시 formData 업데이트
|
||||
useEffect(() => {
|
||||
if (initialData) {
|
||||
setFormData(initialData);
|
||||
// 첫 번째 개소 자동 선택
|
||||
if (initialData.locations.length > 0 && !selectedLocationId) {
|
||||
setSelectedLocationId(initialData.locations[0].id);
|
||||
}
|
||||
}
|
||||
}, [initialData]);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 핸들러
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// 기본 정보 변경
|
||||
const handleFieldChange = useCallback((field: keyof QuoteFormDataV2, value: string) => {
|
||||
setFormData((prev) => ({ ...prev, [field]: value }));
|
||||
}, []);
|
||||
|
||||
// 발주처 선택
|
||||
const handleClientChange = useCallback((clientId: string) => {
|
||||
const client = clients.find((c) => c.id === clientId);
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
clientId,
|
||||
clientName: client?.vendorName || "",
|
||||
}));
|
||||
}, [clients]);
|
||||
|
||||
// 개소 추가
|
||||
const handleAddLocation = useCallback((location: Omit<LocationItem, "id">) => {
|
||||
const newLocation: LocationItem = {
|
||||
...location,
|
||||
id: `loc-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||
};
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
locations: [...prev.locations, newLocation],
|
||||
}));
|
||||
setSelectedLocationId(newLocation.id);
|
||||
toast.success("개소가 추가되었습니다.");
|
||||
}, []);
|
||||
|
||||
// 개소 삭제
|
||||
const handleDeleteLocation = useCallback((locationId: string) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
locations: prev.locations.filter((loc) => loc.id !== locationId),
|
||||
}));
|
||||
if (selectedLocationId === locationId) {
|
||||
setSelectedLocationId(formData.locations[0]?.id || null);
|
||||
}
|
||||
toast.success("개소가 삭제되었습니다.");
|
||||
}, [selectedLocationId, formData.locations]);
|
||||
|
||||
// 개소 수정
|
||||
const handleUpdateLocation = useCallback((locationId: string, updates: Partial<LocationItem>) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
locations: prev.locations.map((loc) =>
|
||||
loc.id === locationId ? { ...loc, ...updates } : loc
|
||||
),
|
||||
}));
|
||||
}, []);
|
||||
|
||||
// 엑셀 업로드
|
||||
const handleExcelUpload = useCallback((locations: Omit<LocationItem, "id">[]) => {
|
||||
const newLocations: LocationItem[] = locations.map((loc) => ({
|
||||
...loc,
|
||||
id: `loc-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||
}));
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
locations: [...prev.locations, ...newLocations],
|
||||
}));
|
||||
if (newLocations.length > 0) {
|
||||
setSelectedLocationId(newLocations[0].id);
|
||||
}
|
||||
toast.success(`${newLocations.length}개 개소가 추가되었습니다.`);
|
||||
}, []);
|
||||
|
||||
// 견적 산출
|
||||
const handleCalculate = useCallback(async () => {
|
||||
if (formData.locations.length === 0) {
|
||||
toast.error("산출할 개소가 없습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsCalculating(true);
|
||||
try {
|
||||
const bomItems = formData.locations.map((loc) => ({
|
||||
finished_goods_code: loc.productCode,
|
||||
openWidth: loc.openWidth,
|
||||
openHeight: loc.openHeight,
|
||||
quantity: loc.quantity,
|
||||
guideRailType: loc.guideRailType,
|
||||
motorPower: loc.motorPower,
|
||||
controller: loc.controller,
|
||||
wingSize: loc.wingSize,
|
||||
inspectionFee: loc.inspectionFee,
|
||||
}));
|
||||
|
||||
const result = await calculateBomBulk(bomItems);
|
||||
|
||||
if (result.success && result.data) {
|
||||
const apiData = result.data as {
|
||||
summary?: { grand_total: number };
|
||||
items?: Array<{ index: number; result: BomCalculationResult }>;
|
||||
};
|
||||
|
||||
// 결과 반영
|
||||
const updatedLocations = formData.locations.map((loc, index) => {
|
||||
const bomResult = apiData.items?.find((item) => item.index === index);
|
||||
if (bomResult?.result) {
|
||||
return {
|
||||
...loc,
|
||||
unitPrice: bomResult.result.grand_total,
|
||||
totalPrice: bomResult.result.grand_total * loc.quantity,
|
||||
bomResult: bomResult.result,
|
||||
};
|
||||
}
|
||||
return loc;
|
||||
});
|
||||
|
||||
setFormData((prev) => ({ ...prev, locations: updatedLocations }));
|
||||
toast.success(`${formData.locations.length}개 개소의 견적이 산출되었습니다.`);
|
||||
} else {
|
||||
toast.error(`견적 산출 실패: ${result.error}`);
|
||||
}
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
toast.error("견적 산출 중 오류가 발생했습니다.");
|
||||
} finally {
|
||||
setIsCalculating(false);
|
||||
}
|
||||
}, [formData.locations]);
|
||||
|
||||
// 저장 (임시/최종)
|
||||
const handleSave = useCallback(async (saveType: "temporary" | "final") => {
|
||||
if (!onSave) return;
|
||||
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const dataToSave: QuoteFormDataV2 = {
|
||||
...formData,
|
||||
status: saveType === "temporary" ? "temporary" : "final",
|
||||
};
|
||||
await onSave(dataToSave, saveType);
|
||||
toast.success(saveType === "temporary" ? "임시 저장되었습니다." : "최종 저장되었습니다.");
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
toast.error("저장 중 오류가 발생했습니다.");
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [formData, onSave]);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 렌더링
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const isViewMode = mode === "view";
|
||||
const pageTitle = mode === "create" ? "견적 등록 (V2 테스트)" : mode === "edit" ? "견적 수정 (V2 테스트)" : "견적 상세 (V2 테스트)";
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* 기본 정보 섹션 */}
|
||||
<div className="p-4 md:p-6 space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold flex items-center gap-2">
|
||||
<FileText className="h-6 w-6" />
|
||||
{pageTitle}
|
||||
</h1>
|
||||
<Badge variant={formData.status === "final" ? "default" : formData.status === "temporary" ? "secondary" : "outline"}>
|
||||
{formData.status === "final" ? "최종저장" : formData.status === "temporary" ? "임시저장" : "작성중"}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* 기본 정보 */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base font-semibold flex items-center gap-2">
|
||||
<FileText className="h-5 w-5" />
|
||||
기본 정보
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-700">등록일</label>
|
||||
<Input
|
||||
type="date"
|
||||
value={formData.registrationDate}
|
||||
disabled
|
||||
className="bg-gray-50"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-700">작성자</label>
|
||||
<Input
|
||||
value={formData.writer}
|
||||
disabled
|
||||
className="bg-gray-50"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-700">발주처 선택 <span className="text-red-500">*</span></label>
|
||||
<Select
|
||||
value={formData.clientId}
|
||||
onValueChange={handleClientChange}
|
||||
disabled={isViewMode || isLoadingClients}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={isLoadingClients ? "로딩 중..." : "발주처를 선택하세요"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{clients.map((client) => (
|
||||
<SelectItem key={client.id} value={client.id}>
|
||||
{client.vendorName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-700">현장명</label>
|
||||
<Input
|
||||
list="siteNameList"
|
||||
placeholder="현장명을 입력하세요"
|
||||
value={formData.siteName}
|
||||
onChange={(e) => handleFieldChange("siteName", e.target.value)}
|
||||
disabled={isViewMode}
|
||||
/>
|
||||
<datalist id="siteNameList">
|
||||
{siteNames.map((name) => (
|
||||
<option key={name} value={name} />
|
||||
))}
|
||||
</datalist>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-700">발주 담당자</label>
|
||||
<Input
|
||||
placeholder="담당자명을 입력하세요"
|
||||
value={formData.manager}
|
||||
onChange={(e) => handleFieldChange("manager", e.target.value)}
|
||||
disabled={isViewMode}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-700">연락처</label>
|
||||
<Input
|
||||
placeholder="010-1234-5678"
|
||||
value={formData.contact}
|
||||
onChange={(e) => handleFieldChange("contact", e.target.value)}
|
||||
disabled={isViewMode}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-700">납기일</label>
|
||||
<Input
|
||||
type="date"
|
||||
value={formData.dueDate}
|
||||
onChange={(e) => handleFieldChange("dueDate", e.target.value)}
|
||||
disabled={isViewMode}
|
||||
/>
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<label className="text-sm font-medium text-gray-700">비고</label>
|
||||
<Textarea
|
||||
placeholder="특이사항을 입력하세요"
|
||||
value={formData.remarks}
|
||||
onChange={(e) => handleFieldChange("remarks", e.target.value)}
|
||||
disabled={isViewMode}
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 자동 견적 산출 섹션 */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3 bg-orange-50 border-b border-orange-200">
|
||||
<CardTitle className="text-base font-semibold flex items-center gap-2 text-orange-800">
|
||||
<Calculator className="h-5 w-5" />
|
||||
자동 견적 산출
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
{/* 좌우 분할 레이아웃 */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 min-h-[500px]">
|
||||
{/* 왼쪽: 발주 개소 목록 + 추가 폼 */}
|
||||
<LocationListPanel
|
||||
locations={formData.locations}
|
||||
selectedLocationId={selectedLocationId}
|
||||
onSelectLocation={setSelectedLocationId}
|
||||
onAddLocation={handleAddLocation}
|
||||
onDeleteLocation={handleDeleteLocation}
|
||||
onExcelUpload={handleExcelUpload}
|
||||
finishedGoods={finishedGoods}
|
||||
disabled={isViewMode}
|
||||
/>
|
||||
|
||||
{/* 오른쪽: 선택 개소 상세 */}
|
||||
<LocationDetailPanel
|
||||
location={selectedLocation}
|
||||
onUpdateLocation={handleUpdateLocation}
|
||||
finishedGoods={finishedGoods}
|
||||
disabled={isViewMode}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 견적 금액 요약 */}
|
||||
<QuoteSummaryPanel
|
||||
locations={formData.locations}
|
||||
selectedLocationId={selectedLocationId}
|
||||
onSelectLocation={setSelectedLocationId}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 푸터 바 (고정) */}
|
||||
<QuoteFooterBar
|
||||
totalLocations={formData.locations.length}
|
||||
totalAmount={totalAmount}
|
||||
status={formData.status}
|
||||
onCalculate={handleCalculate}
|
||||
onPreview={() => setPreviewModalOpen(true)}
|
||||
onSaveTemporary={() => handleSave("temporary")}
|
||||
onSaveFinal={() => handleSave("final")}
|
||||
onBack={onBack}
|
||||
isCalculating={isCalculating}
|
||||
isSaving={isSaving}
|
||||
disabled={isViewMode}
|
||||
/>
|
||||
|
||||
{/* 견적서 미리보기 모달 */}
|
||||
<QuotePreviewModal
|
||||
open={previewModalOpen}
|
||||
onOpenChange={setPreviewModalOpen}
|
||||
quoteData={formData}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
311
src/components/quotes/QuoteSummaryPanel.tsx
Normal file
311
src/components/quotes/QuoteSummaryPanel.tsx
Normal file
@@ -0,0 +1,311 @@
|
||||
/**
|
||||
* 견적 금액 요약 패널
|
||||
*
|
||||
* - 개소별 합계 (왼쪽) - 클릭하여 상세 확인
|
||||
* - 상세별 합계 (오른쪽) - 선택 개소의 카테고리별 금액 및 품목 상세
|
||||
* - 스크롤 가능한 상세 영역
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { Coins } from "lucide-react";
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "../ui/card";
|
||||
|
||||
import type { LocationItem } from "./QuoteRegistrationV2";
|
||||
|
||||
// =============================================================================
|
||||
// 목데이터 - 상세별 합계 (공정별 + 품목 상세)
|
||||
// =============================================================================
|
||||
|
||||
interface DetailItem {
|
||||
name: string;
|
||||
quantity: number;
|
||||
unitPrice: number;
|
||||
totalPrice: number;
|
||||
}
|
||||
|
||||
interface DetailCategory {
|
||||
label: string;
|
||||
count: number;
|
||||
amount: number;
|
||||
items: DetailItem[];
|
||||
}
|
||||
|
||||
const MOCK_DETAIL_TOTALS: DetailCategory[] = [
|
||||
{
|
||||
label: "본체 (스크린/슬랫)",
|
||||
count: 1,
|
||||
amount: 1061676,
|
||||
items: [
|
||||
{ name: "실리카 스크린", quantity: 1, unitPrice: 1061676, totalPrice: 1061676 },
|
||||
]
|
||||
},
|
||||
{
|
||||
label: "절곡품 - 가이드레일",
|
||||
count: 2,
|
||||
amount: 116556,
|
||||
items: [
|
||||
{ name: "벽면형 마감재", quantity: 2, unitPrice: 42024, totalPrice: 84048 },
|
||||
{ name: "본체 가이드 레일", quantity: 2, unitPrice: 16254, totalPrice: 32508 },
|
||||
]
|
||||
},
|
||||
{
|
||||
label: "절곡품 - 케이스",
|
||||
count: 1,
|
||||
amount: 30348,
|
||||
items: [
|
||||
{ name: "전면부 케이스", quantity: 1, unitPrice: 30348, totalPrice: 30348 },
|
||||
]
|
||||
},
|
||||
{
|
||||
label: "절곡품 - 하단마감재",
|
||||
count: 1,
|
||||
amount: 15420,
|
||||
items: [
|
||||
{ name: "하단 하우징", quantity: 1, unitPrice: 15420, totalPrice: 15420 },
|
||||
]
|
||||
},
|
||||
{
|
||||
label: "모터 & 제어기",
|
||||
count: 2,
|
||||
amount: 400000,
|
||||
items: [
|
||||
{ name: "직류 모터", quantity: 1, unitPrice: 250000, totalPrice: 250000 },
|
||||
{ name: "제어기", quantity: 1, unitPrice: 150000, totalPrice: 150000 },
|
||||
]
|
||||
},
|
||||
{
|
||||
label: "부자재",
|
||||
count: 2,
|
||||
amount: 21200,
|
||||
items: [
|
||||
{ name: "각파이프 25mm", quantity: 2, unitPrice: 8500, totalPrice: 17000 },
|
||||
{ name: "플랫바 20mm", quantity: 1, unitPrice: 4200, totalPrice: 4200 },
|
||||
]
|
||||
},
|
||||
];
|
||||
|
||||
// =============================================================================
|
||||
// Props
|
||||
// =============================================================================
|
||||
|
||||
interface QuoteSummaryPanelProps {
|
||||
locations: LocationItem[];
|
||||
selectedLocationId: string | null;
|
||||
onSelectLocation: (id: string) => void;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 컴포넌트
|
||||
// =============================================================================
|
||||
|
||||
export function QuoteSummaryPanel({
|
||||
locations,
|
||||
selectedLocationId,
|
||||
onSelectLocation,
|
||||
}: QuoteSummaryPanelProps) {
|
||||
// ---------------------------------------------------------------------------
|
||||
// 계산된 값
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// 선택된 개소
|
||||
const selectedLocation = useMemo(() => {
|
||||
return locations.find((loc) => loc.id === selectedLocationId) || null;
|
||||
}, [locations, selectedLocationId]);
|
||||
|
||||
// 총 금액
|
||||
const totalAmount = useMemo(() => {
|
||||
return locations.reduce((sum, loc) => sum + (loc.totalPrice || 0), 0);
|
||||
}, [locations]);
|
||||
|
||||
// 개소별 합계
|
||||
const locationTotals = useMemo(() => {
|
||||
return locations.map((loc) => ({
|
||||
id: loc.id,
|
||||
label: `${loc.floor} / ${loc.code}`,
|
||||
productCode: loc.productCode,
|
||||
quantity: loc.quantity,
|
||||
unitPrice: loc.unitPrice || 0,
|
||||
totalPrice: loc.totalPrice || 0,
|
||||
}));
|
||||
}, [locations]);
|
||||
|
||||
// 선택 개소의 상세별 합계 (공정별) - 목데이터 포함
|
||||
const detailTotals = useMemo((): DetailCategory[] => {
|
||||
// bomResult가 없으면 목데이터 사용
|
||||
if (!selectedLocation?.bomResult?.subtotals) {
|
||||
return selectedLocation ? MOCK_DETAIL_TOTALS : [];
|
||||
}
|
||||
|
||||
const subtotals = selectedLocation.bomResult.subtotals;
|
||||
const result: DetailCategory[] = [];
|
||||
|
||||
Object.entries(subtotals).forEach(([key, value]) => {
|
||||
if (typeof value === "object" && value !== null) {
|
||||
result.push({
|
||||
label: value.name || key,
|
||||
count: value.count || 0,
|
||||
amount: value.subtotal || 0,
|
||||
items: value.items || [],
|
||||
});
|
||||
} else if (typeof value === "number") {
|
||||
result.push({
|
||||
label: key,
|
||||
count: 0,
|
||||
amount: value,
|
||||
items: [],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return result;
|
||||
}, [selectedLocation]);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 렌더링
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
return (
|
||||
<Card className="border-gray-200">
|
||||
<CardHeader className="pb-3 bg-gradient-to-r from-blue-50 to-indigo-50 border-b border-blue-200">
|
||||
<CardTitle className="text-base font-semibold flex items-center gap-2">
|
||||
<Coins className="h-5 w-5 text-blue-600" />
|
||||
견적 금액 요약
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
{/* 좌우 분할 */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 divide-y lg:divide-y-0 lg:divide-x divide-gray-200">
|
||||
{/* 왼쪽: 개소별 합계 */}
|
||||
<div className="p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<span className="text-blue-500">📍</span>
|
||||
<h4 className="font-semibold text-gray-700">개소별 합계</h4>
|
||||
</div>
|
||||
|
||||
{locations.length === 0 ? (
|
||||
<div className="text-center text-gray-500 py-6">
|
||||
<p className="text-sm">개소를 추가해주세요</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2 max-h-[300px] overflow-y-auto pr-2">
|
||||
{locationTotals.map((loc) => (
|
||||
<div
|
||||
key={loc.id}
|
||||
className={`flex items-center justify-between p-3 rounded-lg cursor-pointer transition-colors ${
|
||||
selectedLocationId === loc.id
|
||||
? "bg-blue-100 border border-blue-300"
|
||||
: "bg-gray-50 hover:bg-gray-100 border border-transparent"
|
||||
}`}
|
||||
onClick={() => onSelectLocation(loc.id)}
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium">{loc.label}</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
{loc.productCode} × {loc.quantity}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-xs text-gray-500">상세소계</p>
|
||||
<p className="font-bold text-blue-600">
|
||||
{loc.totalPrice.toLocaleString()}
|
||||
</p>
|
||||
{loc.unitPrice > 0 && (
|
||||
<p className="text-xs text-gray-400">
|
||||
수량 적용: {(loc.unitPrice * loc.quantity).toLocaleString()}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 오른쪽: 상세별 합계 */}
|
||||
<div className="p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<span className="text-blue-500">✨</span>
|
||||
<h4 className="font-semibold text-gray-700">
|
||||
상세별 합계
|
||||
{selectedLocation && (
|
||||
<span className="text-sm font-normal text-gray-500 ml-2">
|
||||
({selectedLocation.floor} / {selectedLocation.code})
|
||||
</span>
|
||||
)}
|
||||
</h4>
|
||||
</div>
|
||||
|
||||
{!selectedLocation ? (
|
||||
<div className="text-center text-gray-500 py-6">
|
||||
<p className="text-sm">개소를 선택해주세요</p>
|
||||
</div>
|
||||
) : detailTotals.length === 0 ? (
|
||||
<div className="text-center text-gray-500 py-6">
|
||||
<p className="text-sm">견적 산출 후 상세 금액이 표시됩니다</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3 max-h-[300px] overflow-y-auto pr-2">
|
||||
{detailTotals.map((category, index) => (
|
||||
<div key={index} className="bg-blue-50 rounded-lg overflow-hidden border border-blue-200">
|
||||
{/* 카테고리 헤더 */}
|
||||
<div className="flex items-center justify-between px-3 py-2 bg-blue-100/50">
|
||||
<span className="font-semibold text-gray-700">{category.label}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-gray-500">({category.count}개)</span>
|
||||
<span className="font-bold text-blue-600">
|
||||
{category.amount.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* 품목 상세 목록 */}
|
||||
<div className="divide-y divide-blue-100">
|
||||
{category.items.map((item, itemIndex) => (
|
||||
<div key={itemIndex} className="flex items-center justify-between px-3 py-2 bg-white">
|
||||
<div>
|
||||
<span className="text-sm text-gray-700">{item.name}</span>
|
||||
<p className="text-xs text-gray-400">
|
||||
수량: {item.quantity} × 단가: {item.unitPrice.toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-sm font-medium text-blue-600">
|
||||
{item.totalPrice.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 하단 바: 총 개소 수, 예상 견적금액, 견적 상태 */}
|
||||
<div className="bg-gray-900 text-white px-6 py-5 flex items-center justify-between">
|
||||
<div className="flex items-center gap-10">
|
||||
<div>
|
||||
<p className="text-sm text-gray-400">총 개소 수</p>
|
||||
<p className="text-4xl font-bold">{locations.length}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-400">예상 견적금액</p>
|
||||
<p className="text-4xl font-bold text-blue-400">
|
||||
{totalAmount.toLocaleString()}
|
||||
<span className="text-xl ml-1">원</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-sm text-gray-400">견적 상태</p>
|
||||
<span className="inline-block bg-blue-500/20 text-blue-300 border border-blue-500/50 text-lg px-4 py-1 rounded">
|
||||
작성중
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user