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:
byeongcheolryu
2026-01-12 15:26:17 +09:00
parent e56b7d53a4
commit d036ce4f42
40 changed files with 5292 additions and 141 deletions

View File

@@ -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` | 역할 권한 서비스 |
---

View 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로 이동*

View 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
- 현재 상태: 계획 수립 완료

View File

@@ -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
```
---

View File

@@ -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
View File

@@ -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",

View File

@@ -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"
},

View File

@@ -19,6 +19,8 @@ function getEstimateDetail(id: string): EstimateDetail {
projectName: '현장명',
estimatorId: 'hong',
estimatorName: '이름',
estimateCompanyManager: '홍길동',
estimateCompanyManagerContact: '01012341234',
itemCount: 21,
estimateAmount: 1420000,
completedDate: null,

View File

@@ -19,6 +19,8 @@ function getEstimateDetail(id: string): EstimateDetail {
projectName: '현장명',
estimatorId: 'hong',
estimatorName: '이름',
estimateCompanyManager: '홍길동',
estimateCompanyManagerContact: '01012341234',
itemCount: 21,
estimateAmount: 1420000,
completedDate: null,

View File

@@ -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}
/>
);
}

View File

@@ -0,0 +1,7 @@
'use client';
import { ProjectListClient } from '@/components/business/construction/management';
export default function ProjectManagementPage() {
return <ProjectListClient />;
}

View File

@@ -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">

View File

@@ -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>
</>
)}
{/* 설정 패널 */}

View File

@@ -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}
/>
);
}

View File

@@ -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}
/>
);
}

View File

@@ -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}
/>
);
}

View File

@@ -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"

View File

@@ -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: '계약 생성에 실패했습니다.' };
}
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -78,9 +78,11 @@ export function PriceAdjustmentSection({
>
</Button>
{/* 초기화 버튼 주석처리
<Button type="button" variant="outline" size="sm" onClick={onReset}>
초기화
</Button>
*/}
</div>
)}
</CardHeader>

View File

@@ -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; // 총 개소 (품목 수)

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View 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: '프로젝트 수정에 실패했습니다.' };
}
}

View File

@@ -0,0 +1,4 @@
export { default as ProjectListClient } from './ProjectListClient';
export { default as ProjectGanttChart } from './ProjectGanttChart';
export * from './types';
export * from './actions';

View 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;

View File

@@ -802,7 +802,7 @@ export default function OrderManagementListClient({
onMonthChange={handleCalendarMonthChange}
titleSlot="발주 스케줄"
filterSlot={calendarFilterSlot}
maxEventsPerDay={3}
maxEventsPerDay={5}
weekStartsOn={0}
isLoading={isLoading}
/>

View File

@@ -32,7 +32,7 @@ export function OrderScheduleCard({
onDateClick={onDateClick}
onEventClick={() => {}}
onMonthChange={onMonthChange}
maxEventsPerDay={3}
maxEventsPerDay={5}
weekStartsOn={0}
isLoading={false}
/>

View File

@@ -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'
)}
>

View File

@@ -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}
/>

View File

@@ -40,7 +40,7 @@ export function ScheduleCalendar({
onViewChange,
titleSlot,
filterSlot,
maxEventsPerDay = 3,
maxEventsPerDay = 5,
weekStartsOn = 0,
isLoading = false,
className,

View File

@@ -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);
}
/**
* 두 날짜가 같은지 확인
*/

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}