diff --git a/claudedocs/[ANALYSIS-2026-01-07] permission-system-status.md b/claudedocs/[ANALYSIS-2026-01-07] permission-system-status.md index f5bc28ad..a97fb758 100644 --- a/claudedocs/[ANALYSIS-2026-01-07] permission-system-status.md +++ b/claudedocs/[ANALYSIS-2026-01-07] permission-system-status.md @@ -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 && } {canDelete && } +{canApprove && } ``` **환경 변수 플래그**: @@ -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` | 역할 권한 서비스 | --- diff --git a/claudedocs/[IMPL-2026-01-12] permission-frontend-checklist.md b/claudedocs/[IMPL-2026-01-12] permission-frontend-checklist.md new file mode 100644 index 00000000..f0624ef7 --- /dev/null +++ b/claudedocs/[IMPL-2026-01-12] permission-frontend-checklist.md @@ -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`) + - [ ] `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 + + + + ``` + +--- + +## 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로 이동* \ No newline at end of file diff --git a/claudedocs/[IMPL-2026-01-12] quote-v2-test-pages-checklist.md b/claudedocs/[IMPL-2026-01-12] quote-v2-test-pages-checklist.md new file mode 100644 index 00000000..9e0dcda1 --- /dev/null +++ b/claudedocs/[IMPL-2026-01-12] quote-v2-test-pages-checklist.md @@ -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 +- 현재 상태: 계획 수립 완료 \ No newline at end of file diff --git a/claudedocs/[REF] all-pages-test-urls.md b/claudedocs/[REF] all-pages-test-urls.md index b0e02f45..09588884 100644 --- a/claudedocs/[REF] all-pages-test-urls.md +++ b/claudedocs/[REF] all-pages-test-urls.md @@ -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 ``` --- diff --git a/claudedocs/[REF] construction-pages-test-urls.md b/claudedocs/[REF] construction-pages-test-urls.md index be60f13a..effd679f 100644 --- a/claudedocs/[REF] construction-pages-test-urls.md +++ b/claudedocs/[REF] construction-pages-test-urls.md @@ -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` | ✅ 완료 | diff --git a/package-lock.json b/package-lock.json index bb423423..d748cda5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 2880e0c8..f645fdc1 100644 --- a/package.json +++ b/package.json @@ -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" }, diff --git a/src/app/[locale]/(protected)/construction/project/bidding/estimates/[id]/edit/page.tsx b/src/app/[locale]/(protected)/construction/project/bidding/estimates/[id]/edit/page.tsx index 311890aa..123c421b 100644 --- a/src/app/[locale]/(protected)/construction/project/bidding/estimates/[id]/edit/page.tsx +++ b/src/app/[locale]/(protected)/construction/project/bidding/estimates/[id]/edit/page.tsx @@ -19,6 +19,8 @@ function getEstimateDetail(id: string): EstimateDetail { projectName: '현장명', estimatorId: 'hong', estimatorName: '이름', + estimateCompanyManager: '홍길동', + estimateCompanyManagerContact: '01012341234', itemCount: 21, estimateAmount: 1420000, completedDate: null, diff --git a/src/app/[locale]/(protected)/construction/project/bidding/estimates/[id]/page.tsx b/src/app/[locale]/(protected)/construction/project/bidding/estimates/[id]/page.tsx index 22385d31..e754deff 100644 --- a/src/app/[locale]/(protected)/construction/project/bidding/estimates/[id]/page.tsx +++ b/src/app/[locale]/(protected)/construction/project/bidding/estimates/[id]/page.tsx @@ -19,6 +19,8 @@ function getEstimateDetail(id: string): EstimateDetail { projectName: '현장명', estimatorId: 'hong', estimatorName: '이름', + estimateCompanyManager: '홍길동', + estimateCompanyManagerContact: '01012341234', itemCount: 21, estimateAmount: 1420000, completedDate: null, diff --git a/src/app/[locale]/(protected)/construction/project/contract/create/page.tsx b/src/app/[locale]/(protected)/construction/project/contract/create/page.tsx new file mode 100644 index 00000000..1dcc7188 --- /dev/null +++ b/src/app/[locale]/(protected)/construction/project/contract/create/page.tsx @@ -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(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 ( +
+
로딩 중...
+
+ ); + } + + return ( + + ); +} \ No newline at end of file diff --git a/src/app/[locale]/(protected)/construction/project/management/page.tsx b/src/app/[locale]/(protected)/construction/project/management/page.tsx new file mode 100644 index 00000000..ec0f791c --- /dev/null +++ b/src/app/[locale]/(protected)/construction/project/management/page.tsx @@ -0,0 +1,7 @@ +'use client'; + +import { ProjectListClient } from '@/components/business/construction/management'; + +export default function ProjectManagementPage() { + return ; +} diff --git a/src/app/[locale]/(protected)/quality/qms/components/Day1ChecklistPanel.tsx b/src/app/[locale]/(protected)/quality/qms/components/Day1ChecklistPanel.tsx index 8d788c8a..92cb2d9f 100644 --- a/src/app/[locale]/(protected)/quality/qms/components/Day1ChecklistPanel.tsx +++ b/src/app/[locale]/(protected)/quality/qms/components/Day1ChecklistPanel.tsx @@ -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>( 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 (
- {/* 헤더 + 검색 */} + {/* 헤더 */}
-

점검표 항목

- {/* 검색 입력 */} -
- - 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 && ( - - )} -
+

점검표 항목

{/* 검색 결과 카운트 */} {searchTerm && (
diff --git a/src/app/[locale]/(protected)/quality/qms/page.tsx b/src/app/[locale]/(protected)/quality/qms/page.tsx index 195db457..f2a0ebb8 100644 --- a/src/app/[locale]/(protected)/quality/qms/page.tsx +++ b/src/app/[locale]/(protected)/quality/qms/page.tsx @@ -270,6 +270,16 @@ export default function QualityInspectionPage() {
)} + {/* 공통 필터 (1일차/2일차 모두 사용) */} + + {activeDay === 1 ? ( // ===== 1일차: 기준/매뉴얼 심사 =====
@@ -284,6 +294,7 @@ export default function QualityInspectionPage() { @@ -315,17 +326,7 @@ export default function QualityInspectionPage() {
) : ( // ===== 2일차: 로트추적 심사 ===== - <> - - -
+
- )} {/* 설정 패널 */} diff --git a/src/app/[locale]/(protected)/sales/quote-management/test-new/page.tsx b/src/app/[locale]/(protected)/sales/quote-management/test-new/page.tsx new file mode 100644 index 00000000..872bc423 --- /dev/null +++ b/src/app/[locale]/(protected)/sales/quote-management/test-new/page.tsx @@ -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 ( + + ); +} diff --git a/src/app/[locale]/(protected)/sales/quote-management/test/[id]/edit/page.tsx b/src/app/[locale]/(protected)/sales/quote-management/test/[id]/edit/page.tsx new file mode 100644 index 00000000..afb22c59 --- /dev/null +++ b/src/app/[locale]/(protected)/sales/quote-management/test/[id]/edit/page.tsx @@ -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(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 ; + } + + return ( + + ); +} diff --git a/src/app/[locale]/(protected)/sales/quote-management/test/[id]/page.tsx b/src/app/[locale]/(protected)/sales/quote-management/test/[id]/page.tsx new file mode 100644 index 00000000..4e8eadb6 --- /dev/null +++ b/src/app/[locale]/(protected)/sales/quote-management/test/[id]/page.tsx @@ -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(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 ; + } + + return ( + + ); +} diff --git a/src/components/business/construction/contract/ContractDetailForm.tsx b/src/components/business/construction/contract/ContractDetailForm.tsx index e9f12e4e..f2b269ce 100644 --- a/src/components/business/construction/contract/ContractDetailForm.tsx +++ b/src/components/business/construction/contract/ContractDetailForm.tsx @@ -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( @@ -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 ? (
+
+ ) : isCreateMode ? ( +
+ + +
) : (
); + // 페이지 타이틀 + const pageTitle = isCreateMode + ? (isChangeContract ? '변경 계약서 생성' : '계약 등록') + : '계약 상세'; + return (
- {/* 파일 선택 버튼 (수정 모드에서만) */} - {isEditMode && ( + {/* 파일 선택 버튼 (수정/생성 모드에서만) */} + {(isEditMode || isCreateMode) && ( @@ -498,7 +541,7 @@ export default function ContractDetailForm({ {formData.contractFile.name} (새 파일)
- {isEditMode && ( + {(isEditMode || isCreateMode) && ( - {isEditMode && ( + {(isEditMode || isCreateMode) && ( - {isEditMode && ( + {(isEditMode || isCreateMode) && ( + {/* 초기화 버튼 주석처리 + */}
)} diff --git a/src/components/business/construction/estimates/types.ts b/src/components/business/construction/estimates/types.ts index 80a30a90..bf859dfc 100644 --- a/src/components/business/construction/estimates/types.ts +++ b/src/components/business/construction/estimates/types.ts @@ -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; // 총 개소 (품목 수) diff --git a/src/components/business/construction/management/ProjectGanttChart.tsx b/src/components/business/construction/management/ProjectGanttChart.tsx new file mode 100644 index 00000000..6ee155de --- /dev/null +++ b/src/components/business/construction/management/ProjectGanttChart.tsx @@ -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(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(); + 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 ( +
+ {/* 헤더: 날짜 네비게이션 */} +
+
+ + + {viewMode === 'day' + ? `${currentDate.getFullYear()}년 ${currentDate.getMonth() + 1}월` + : viewMode === 'week' + ? `${currentDate.getFullYear()}년 ${currentDate.getMonth()}월 ~ ${currentDate.getMonth() + 2}월` + : `${currentDate.getFullYear() - 1}년 ~ ${currentDate.getFullYear()}년`} + + +
+ + {/* 범례 */} +
+
+
+ 진행중 +
+
+
+ 종료 +
+
+
+ 긴급 이슈 +
+
+
+ + {/* 차트 영역 */} +
+
setIsScrolling(true)} + onMouseUp={() => setIsScrolling(false)} + onMouseLeave={() => setIsScrolling(false)} + > +
+ {/* 전체를 하나의 세로 스크롤 영역으로 */} +
+ {/* 연도 헤더 (월 모드에서만) */} + {viewMode === 'month' && yearGroups && ( +
+ {yearGroups.map((group) => ( +
+ {group.year}년 +
+ ))} +
+ )} + + {/* 월 헤더 (주 모드에서만) */} + {viewMode === 'week' && monthGroups && ( +
+ {monthGroups.map((group, idx) => ( +
+ {group.label} +
+ ))} +
+ )} + + {/* 컬럼 헤더 - 날짜/주/월 */} +
+ {columns.map((col, idx) => ( +
+ {col.label} +
+ ))} +
+ + {/* 프로젝트 행들 (가로선 없음) */} + {projects.length === 0 ? ( +
+ 표시할 프로젝트가 없습니다. +
+ ) : ( + projects.map((project) => { + const barStyle = getBarStyle(project); + return ( +
!isScrolling && onProjectClick(project)} + > + {/* 그리드 세로선 */} +
+ {columns.map((_, idx) => ( +
+ ))} +
+ + {/* 막대 - 프로젝트명 직접 표시 */} + {barStyle && ( +
+ + [{project.partnerName}] {project.siteName} {project.progressRate}% + +
+ )} +
+ ); + }) + )} +
+
+
+
+
+ ); +} diff --git a/src/components/business/construction/management/ProjectListClient.tsx b/src/components/business/construction/management/ProjectListClient.tsx new file mode 100644 index 00000000..f97496e2 --- /dev/null +++ b/src/components/business/construction/management/ProjectListClient.tsx @@ -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 ( +
+ + {open && ( + <> +
setOpen(false)} /> +
+
handleToggle('all')} + > + + 전체 +
+ {options.map((option) => ( +
handleToggle(option.value)} + > + + {option.label} +
+ ))} +
+ + )} +
+ ); +} + +interface ProjectListClientProps { + initialData?: Project[]; + initialStats?: ProjectStats; +} + +export default function ProjectListClient({ initialData = [], initialStats }: ProjectListClientProps) { + const router = useRouter(); + + // 상태 + const [projects, setProjects] = useState(initialData); + const [stats, setStats] = useState( + 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('day'); + // TODO: 실제 API 연동 시 new Date()로 변경 (현재 목업 데이터가 2025년이라 임시 설정) + const [chartDate, setChartDate] = useState(new Date(2025, 0, 15)); + const [chartPartnerFilter, setChartPartnerFilter] = useState(['all']); + const [chartSiteFilter, setChartSiteFilter] = useState(['all']); + + // 테이블 필터 + const [partnerFilter, setPartnerFilter] = useState(['all']); + const [contractManagerFilter, setContractManagerFilter] = useState(['all']); + const [pmFilter, setPmFilter] = useState(['all']); + const [statusFilter, setStatusFilter] = useState('all'); + const [sortBy, setSortBy] = useState<'latest' | 'progress' | 'register' | 'completion'>('latest'); + + // 필터 옵션들 + const [partnerOptions, setPartnerOptions] = useState([]); + const [siteOptions, setSiteOptions] = useState([]); + const [contractManagerOptions, setContractManagerOptions] = useState([]); + const [pmOptions, setPmOptions] = useState([]); + + // 테이블 상태 + const [selectedItems, setSelectedItems] = useState>(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 긴급; + } + switch (status) { + case 'completed': + return 완료; + case 'in_progress': + return 진행중; + default: + return {status}; + } + }; + + const allSelected = selectedItems.size === paginatedData.length && paginatedData.length > 0; + + return ( + + {/* 페이지 헤더 */} + + + {/* 기간 선택 (달력 + 프리셋 버튼) */} + + + {/* 상태 카드 */} +
+ + +
+
+ +
+
+

전체 프로젝트

+

{stats.total}

+
+
+
+
+ + +
+
+ +
+
+

프로젝트 진행

+

{stats.inProgress}

+
+
+
+
+ + +
+
+ +
+
+

프로젝트 완료

+

{stats.completed}

+
+
+
+
+
+ + {/* 프로젝트 일정 간트차트 */} + + +
+ {/* 간트차트 상단 컨트롤 */} +
+
+ 프로젝트 일정 +
+
+ {/* 일/주/월 전환 */} +
+ + + +
+ + {/* 거래처 필터 */} + + + {/* 현장 필터 */} + +
+
+ + {/* 간트차트 */} + +
+
+
+ + {/* 테이블 영역 */} + + + {/* 테이블 헤더 (필터들) */} +
+ + 총 {projects.length}건 + +
+ {/* 거래처 필터 */} + { + setPartnerFilter(v); + setCurrentPage(1); + }} + /> + + {/* 계약담당자 필터 */} + { + setContractManagerFilter(v); + setCurrentPage(1); + }} + /> + + {/* 공사PM 필터 */} + { + setPmFilter(v); + setCurrentPage(1); + }} + /> + + {/* 상태 필터 */} + + + {/* 정렬 */} + +
+
+ + {/* 데스크톱 테이블 */} +
+ + + + + + + 번호 + 계약번호 + 거래처 + 현장명 + 계약담당자 + 공사PM + 총 개소 + 계약금액 + 진행률 + 누계 기성 + 프로젝트 기간 + 상태 + 작업 + + + + {paginatedData.length === 0 ? ( + + + 검색 결과가 없습니다. + + + ) : ( + paginatedData.map((project, index) => { + const isSelected = selectedItems.has(project.id); + const globalIndex = startIndex + index + 1; + + return ( + handleRowClick(project)} + > + e.stopPropagation()}> + handleToggleSelection(project.id)} + /> + + {globalIndex} + {project.contractNumber} + {project.partnerName} + {project.siteName} + {project.contractManager} + {project.constructionPM} + {project.totalLocations} + {formatAmount(project.contractAmount)} + {project.progressRate}% + {formatAmount(project.accumulatedPayment)} + + {formatDate(project.startDate)} ~ {formatDate(project.endDate)} + + + {getStatusBadge(project.status, project.hasUrgentIssue)} + + + {isSelected && ( + + )} + + + ); + }) + )} + +
+
+ + {/* 모바일/태블릿 카드 뷰 */} +
+ {projects.length === 0 ? ( +
+ 검색 결과가 없습니다. +
+ ) : ( + projects.map((project, index) => { + const isSelected = selectedItems.has(project.id); + return ( + 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) }, + ]} + /> + ); + }) + )} +
+
+
+ + {/* 페이지네이션 */} + {totalPages > 1 && ( +
+
+ 전체 {projects.length}개 중 {startIndex + 1}-{Math.min(startIndex + itemsPerPage, projects.length)}개 표시 +
+
+ +
+ {Array.from({ length: totalPages }, (_, i) => i + 1).map((page) => { + if ( + page === 1 || + page === totalPages || + (page >= currentPage - 2 && page <= currentPage + 2) + ) { + return ( + + ); + } else if (page === currentPage - 3 || page === currentPage + 3) { + return ...; + } + return null; + })} +
+ +
+
+ )} +
+ ); +} diff --git a/src/components/business/construction/management/actions.ts b/src/components/business/construction/management/actions.ts new file mode 100644 index 00000000..6a810b1a --- /dev/null +++ b/src/components/business/construction/management/actions.ts @@ -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 +): 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: '프로젝트 수정에 실패했습니다.' }; + } +} \ No newline at end of file diff --git a/src/components/business/construction/management/index.ts b/src/components/business/construction/management/index.ts new file mode 100644 index 00000000..1b34ad84 --- /dev/null +++ b/src/components/business/construction/management/index.ts @@ -0,0 +1,4 @@ +export { default as ProjectListClient } from './ProjectListClient'; +export { default as ProjectGanttChart } from './ProjectGanttChart'; +export * from './types'; +export * from './actions'; diff --git a/src/components/business/construction/management/types.ts b/src/components/business/construction/management/types.ts new file mode 100644 index 00000000..1990d447 --- /dev/null +++ b/src/components/business/construction/management/types.ts @@ -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; \ No newline at end of file diff --git a/src/components/business/construction/order-management/OrderManagementListClient.tsx b/src/components/business/construction/order-management/OrderManagementListClient.tsx index f021501a..86b37bb5 100644 --- a/src/components/business/construction/order-management/OrderManagementListClient.tsx +++ b/src/components/business/construction/order-management/OrderManagementListClient.tsx @@ -802,7 +802,7 @@ export default function OrderManagementListClient({ onMonthChange={handleCalendarMonthChange} titleSlot="발주 스케줄" filterSlot={calendarFilterSlot} - maxEventsPerDay={3} + maxEventsPerDay={5} weekStartsOn={0} isLoading={isLoading} /> diff --git a/src/components/business/construction/order-management/cards/OrderScheduleCard.tsx b/src/components/business/construction/order-management/cards/OrderScheduleCard.tsx index 9f6f3c6d..dfb2438c 100644 --- a/src/components/business/construction/order-management/cards/OrderScheduleCard.tsx +++ b/src/components/business/construction/order-management/cards/OrderScheduleCard.tsx @@ -32,7 +32,7 @@ export function OrderScheduleCard({ onDateClick={onDateClick} onEventClick={() => {}} onMonthChange={onMonthChange} - maxEventsPerDay={3} + maxEventsPerDay={5} weekStartsOn={0} isLoading={false} /> diff --git a/src/components/common/ScheduleCalendar/DayCell.tsx b/src/components/common/ScheduleCalendar/DayCell.tsx index 86b44fd2..c8aa7d5a 100644 --- a/src/components/common/ScheduleCalendar/DayCell.tsx +++ b/src/components/common/ScheduleCalendar/DayCell.tsx @@ -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' )} > diff --git a/src/components/common/ScheduleCalendar/MonthView.tsx b/src/components/common/ScheduleCalendar/MonthView.tsx index f8cb071d..fcf2df3d 100644 --- a/src/components/common/ScheduleCalendar/MonthView.tsx +++ b/src/components/common/ScheduleCalendar/MonthView.tsx @@ -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 (
diff --git a/src/components/common/ScheduleCalendar/ScheduleCalendar.tsx b/src/components/common/ScheduleCalendar/ScheduleCalendar.tsx index eddc8100..96518c2d 100644 --- a/src/components/common/ScheduleCalendar/ScheduleCalendar.tsx +++ b/src/components/common/ScheduleCalendar/ScheduleCalendar.tsx @@ -40,7 +40,7 @@ export function ScheduleCalendar({ onViewChange, titleSlot, filterSlot, - maxEventsPerDay = 3, + maxEventsPerDay = 5, weekStartsOn = 0, isLoading = false, className, diff --git a/src/components/common/ScheduleCalendar/utils.ts b/src/components/common/ScheduleCalendar/utils.ts index 52f7fcb0..989e0172 100644 --- a/src/components/common/ScheduleCalendar/utils.ts +++ b/src/components/common/ScheduleCalendar/utils.ts @@ -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); +} + /** * 두 날짜가 같은지 확인 */ diff --git a/src/components/quotes/ItemSearchModal.tsx b/src/components/quotes/ItemSearchModal.tsx new file mode 100644 index 00000000..73fa5bba --- /dev/null +++ b/src/components/quotes/ItemSearchModal.tsx @@ -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 ( + + + + 품목 검색 + + + {/* 검색 입력 */} +
+ + setSearchQuery(e.target.value)} + className="pl-10" + /> + {searchQuery && ( + + )} +
+ + {/* 품목 목록 */} +
+ {filteredItems.length === 0 ? ( +
+ 검색 결과가 없습니다 +
+ ) : ( + filteredItems.map((item) => ( +
handleSelect(item)} + className="p-3 hover:bg-blue-50 cursor-pointer transition-colors" + > +
+
+ {item.code} + {item.name} +
+
+ {item.description && ( +

{item.description}

+ )} +
+ )) + )} +
+
+
+ ); +} \ No newline at end of file diff --git a/src/components/quotes/LocationDetailPanel.tsx b/src/components/quotes/LocationDetailPanel.tsx new file mode 100644 index 00000000..5a876291 --- /dev/null +++ b/src/components/quotes/LocationDetailPanel.tsx @@ -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) => 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 = { + 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 = {}; + 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 ( +
+ +

개소를 선택해주세요

+

왼쪽 목록에서 개소를 선택하면 상세 정보가 표시됩니다

+
+ ); + } + + // --------------------------------------------------------------------------- + // 렌더링: 상세 정보 + // --------------------------------------------------------------------------- + + return ( +
+ {/* 헤더 */} +
+
+

+ {location.floor} / {location.code} +

+
+ 제품명: + + {location.productCode} + + {location.bomResult && ( + + 산출완료 + + )} +
+
+
+ + {/* 제품 정보 */} +
+ {/* 오픈사이즈 */} +
+ 오픈사이즈 +
+ handleFieldChange("openWidth", parseFloat(e.target.value) || 0)} + disabled={disabled} + className="w-24 h-8 text-center font-bold" + /> + × + handleFieldChange("openHeight", parseFloat(e.target.value) || 0)} + disabled={disabled} + className="w-24 h-8 text-center font-bold" + /> + {!disabled && ( + 수정 + )} +
+
+ + {/* 제작사이즈, 산출중량, 산출면적, 수량 */} +
+
+ 제작사이즈 +

+ {location.manufactureWidth || location.openWidth + 280} × {location.manufactureHeight || location.openHeight + 280} +

+
+
+ 산출중량 +

{location.weight?.toFixed(1) || "-"} kg

+
+
+ 산출면적 +

{location.area?.toFixed(1) || "-"}

+
+
+ 수량 + handleFieldChange("quantity", parseInt(e.target.value) || 1)} + disabled={disabled} + className="w-24 h-7 text-center font-semibold" + /> +
+
+
+ + {/* 필수 설정 (읽기 전용) */} +
+

+ + 필수 설정 +

+
+
+ + + {GUIDE_RAIL_TYPES.find(t => t.value === location.guideRailType)?.label || location.guideRailType} + +
+
+ + + {MOTOR_POWERS.find(p => p.value === location.motorPower)?.label || location.motorPower} + +
+
+ + + {CONTROLLERS.find(c => c.value === location.controller)?.label || location.controller} + +
+
+
+ + {/* 탭 및 품목 테이블 */} +
+ + {/* 탭 목록 - 스크롤 가능 */} +
+ + {DETAIL_TABS.map((tab) => ( + + {tab.label} + + ))} + +
+ + {/* 본체 (스크린/슬랫) 탭 */} + +
+ + + + 품목명 + 제작사이즈 + 수량 + 작업 + + + + {bomItemsByTab.body.map((item: any) => ( + + {item.item_name} + {item.manufacture_size || "-"} + + + + + + + + ))} + +
+ + {/* 품목 추가 버튼 + 안내 */} +
+ + + 💡 금액은 아래 견적금액요약에서 확인하세요 + +
+
+
+ + {/* 절곡품 - 가이드레일, 케이스, 하단마감재 탭 */} + {["guide-rail", "case", "bottom"].map((tabValue) => ( + +
+ + + + 품목명 + 재질 + 규격 + 납품길이 + 수량 + 작업 + + + + {bomItemsByTab[tabValue]?.map((item: any) => ( + + {item.item_name} + {item.material || "-"} + {item.spec || "-"} + + + + + + + + + + + ))} + +
+ + {/* 품목 추가 버튼 + 안내 */} +
+ + + 💡 금액은 아래 견적금액요약에서 확인하세요 + +
+
+
+ ))} + + {/* 모터 & 제어기 탭 */} + +
+ + + + 품목명 + 유형 + 사양 + 수량 + 작업 + + + + {bomItemsByTab.motor?.map((item: any) => ( + + {item.item_name} + {item.type || "-"} + {item.spec || "-"} + + + + + + + + ))} + +
+ + {/* 품목 추가 버튼 + 안내 */} +
+ + + 💡 금액은 아래 견적금액요약에서 확인하세요 + +
+
+
+ + {/* 부자재 탭 */} + +
+ + + + 품목명 + 규격 + 납품길이 + 수량 + 작업 + + + + {bomItemsByTab.accessory?.map((item: any) => ( + + {item.item_name} + {item.spec || "-"} + + + + + + + + + + + ))} + +
+ + {/* 품목 추가 버튼 + 안내 */} +
+ + + 💡 금액은 아래 견적금액요약에서 확인하세요 + +
+
+
+
+
+ + {/* 금액 안내 */} + {!location.bomResult && ( +
+ 💡 금액은 아래 견적금액요약에서 확인하세요 +
+ )} + + {/* 품목 검색 모달 */} + { + console.log(`[테스트] 품목 선택: ${item.code} - ${item.name} (탭: ${activeTab})`); + }} + tabLabel={DETAIL_TABS.find((t) => t.value === activeTab)?.label} + /> +
+ ); +} \ No newline at end of file diff --git a/src/components/quotes/LocationListPanel.tsx b/src/components/quotes/LocationListPanel.tsx new file mode 100644 index 00000000..6ecf656d --- /dev/null +++ b/src/components/quotes/LocationListPanel.tsx @@ -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) => void; + onDeleteLocation: (id: string) => void; + onExcelUpload: (locations: Omit[]) => 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(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 = { + 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) => { + 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[] = 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 ( +
+ {/* 헤더 */} +
+
+

+ 📋 발주 개소 목록 ({locations.length}) +

+
+ + +
+
+
+ + {/* 개소 목록 테이블 */} +
+ + + + + 부호 + 사이즈 + 제품 + 수량 + + + + + {locations.length === 0 ? ( + + + 개소를 추가해주세요 + + + ) : ( + locations.map((loc) => ( + onSelectLocation(loc.id)} + > + {loc.floor} + {loc.code} + + {loc.openWidth}×{loc.openHeight} + + {loc.productCode} + {loc.quantity} + + {!disabled && ( + + )} + + + )) + )} + +
+
+ + {/* 추가 폼 */} + {!disabled && ( +
+ {/* 1행: 층, 부호, 가로, 세로, 제품명, 수량 */} +
+
+ + handleFormChange("floor", e.target.value)} + className="h-8 text-sm" + /> +
+
+ + handleFormChange("code", e.target.value)} + className="h-8 text-sm" + /> +
+
+ + handleFormChange("openWidth", e.target.value)} + className="h-8 text-sm" + /> +
+
+ + handleFormChange("openHeight", e.target.value)} + className="h-8 text-sm" + /> +
+
+ + +
+
+ + handleFormChange("quantity", e.target.value)} + className="h-8 text-sm" + /> +
+
+ + {/* 2행: 가이드레일, 전원, 제어기, 버튼 */} +
+
+ + +
+
+ + +
+
+ + +
+ +
+
+ )} + + {/* 삭제 확인 다이얼로그 */} + setDeleteTarget(null)}> + + + 개소 삭제 + + 선택한 개소를 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다. + + + + 취소 + { + if (deleteTarget) { + onDeleteLocation(deleteTarget); + setDeleteTarget(null); + } + }} + className="bg-red-500 hover:bg-red-600" + > + 삭제 + + + + +
+ ); +} \ No newline at end of file diff --git a/src/components/quotes/QuoteFooterBar.tsx b/src/components/quotes/QuoteFooterBar.tsx new file mode 100644 index 00000000..26efc771 --- /dev/null +++ b/src/components/quotes/QuoteFooterBar.tsx @@ -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 ( +
+
+ {/* 왼쪽: 뒤로가기 + 금액 표시 */} +
+ + +
+

예상 전체 견적금액

+

+ {totalAmount.toLocaleString()} + +

+
+
+ + {/* 오른쪽: 버튼들 */} +
+ {/* 견적서 산출 */} + + + {/* 미리보기 */} + + + {/* 임시저장 */} + + + {/* 최종저장 */} + +
+
+
+ ); +} \ No newline at end of file diff --git a/src/components/quotes/QuotePreviewModal.tsx b/src/components/quotes/QuotePreviewModal.tsx new file mode 100644 index 00000000..897b133e --- /dev/null +++ b/src/components/quotes/QuotePreviewModal.tsx @@ -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 ( + + + + 견적서 상세 + + + {/* 헤더 영역 - 제목 + 닫기 버튼 */} +
+

견적서

+ +
+ + {/* 버튼 영역 - PDF, 이메일, 인쇄 */} +
+ + + +
+ + {/* 문서 영역 - 스크롤 */} +
+
+ {/* 제목 */} +
+

견 적 서

+

+ 문서번호: {quoteData.id || "-"} | 작성일자: {quoteData.registrationDate || "-"} +

+
+ + {/* 수요자 정보 */} +
+
+ 수 요 자 +
+
+
+ 업체명 + {quoteData.clientName || "-"} +
+
+ 담당자 + {quoteData.manager || "-"} +
+
+ 프로젝트명 + {quoteData.siteName || "-"} +
+
+ 연락처 + {quoteData.contact || "-"} +
+
+ 견적일자 + {quoteData.registrationDate || "-"} +
+
+ 유효기간 + {quoteData.dueDate || "-"} +
+
+
+ + {/* 공급자 정보 */} +
+
+ 공 급 자 +
+
+
+ 상호 + 프론트_테스트회사 +
+
+ 사업자등록번호 + 123-45-67890 +
+
+ 대표자 + 프론트 +
+
+ 업태 + 업태명 +
+
+ 종목 + 김종명 +
+
+ 사업장주소 + 07547 서울 강서구 양천로 583 B-1602 +
+
+ 전화 + 01048209104 +
+
+ 이메일 + codebridgex@codebridge-x.com +
+
+
+ + {/* 총 견적금액 */} +
+

총 견적금액

+

+ ₩ {grandTotal.toLocaleString()} +

+

※ 부가가치세 포함

+
+ + {/* 제품 구성정보 */} +
+
+ 제 품 구 성 정 보 +
+
+
+ 모델 + + {quoteData.locations[0]?.productCode || "-"} + +
+
+ 총 수량 + {quoteData.locations.length}개소 +
+
+ 오픈사이즈 + + {quoteData.locations[0]?.openWidth || "-"} × {quoteData.locations[0]?.openHeight || "-"} + +
+
+ 설치유형 + - +
+
+
+ + {/* 품목 내역 */} +
+
+ 품 목 내 역 +
+ + + + + + + + + + + + + + {quoteData.locations.map((loc, index) => ( + + + + + + + + + + ))} + + + + + + + + + + + + + + + + + + +
No.품목명규격수량단위단가금액
{index + 1}{loc.productCode} + {loc.openWidth}×{loc.openHeight} + {loc.quantity}EA + {(loc.unitPrice || 0).toLocaleString()} + + {(loc.totalPrice || 0).toLocaleString()} +
공급가액 합계 + {totalAmount.toLocaleString()} +
부가가치세 (10%) + {vat.toLocaleString()} +
총 견적금액 + {grandTotal.toLocaleString()} +
+
+ + {/* 비고사항 */} +
+
+ 비 고 사 항 +
+
+ {quoteData.remarks || "비고 테스트"} +
+
+
+
+
+
+ ); +} \ No newline at end of file diff --git a/src/components/quotes/QuoteRegistrationV2.tsx b/src/components/quotes/QuoteRegistrationV2.tsx new file mode 100644 index 00000000..d1907751 --- /dev/null +++ b/src/components/quotes/QuoteRegistrationV2.tsx @@ -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; + onCalculate?: () => void; + initialData?: QuoteFormDataV2 | null; + isLoading?: boolean; +} + +// ============================================================================= +// 메인 컴포넌트 +// ============================================================================= + +export function QuoteRegistrationV2({ + mode, + onBack, + onSave, + onCalculate, + initialData, + isLoading = false, +}: QuoteRegistrationV2Props) { + // --------------------------------------------------------------------------- + // 상태 + // --------------------------------------------------------------------------- + const [formData, setFormData] = useState( + initialData || INITIAL_FORM_DATA + ); + const [selectedLocationId, setSelectedLocationId] = useState(null); + const [isSaving, setIsSaving] = useState(false); + const [isCalculating, setIsCalculating] = useState(false); + const [previewModalOpen, setPreviewModalOpen] = useState(false); + + // API 데이터 + const [clients, setClients] = useState([]); + const [finishedGoods, setFinishedGoods] = useState([]); + const [siteNames, setSiteNames] = useState([]); + 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) => { + 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) => { + setFormData((prev) => ({ + ...prev, + locations: prev.locations.map((loc) => + loc.id === locationId ? { ...loc, ...updates } : loc + ), + })); + }, []); + + // 엑셀 업로드 + const handleExcelUpload = useCallback((locations: Omit[]) => { + 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 ( +
+ {/* 기본 정보 섹션 */} +
+
+

+ + {pageTitle} +

+ + {formData.status === "final" ? "최종저장" : formData.status === "temporary" ? "임시저장" : "작성중"} + +
+ + {/* 기본 정보 */} + + + + + 기본 정보 + + + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ + handleFieldChange("siteName", e.target.value)} + disabled={isViewMode} + /> + + {siteNames.map((name) => ( + +
+
+ + handleFieldChange("manager", e.target.value)} + disabled={isViewMode} + /> +
+
+ + handleFieldChange("contact", e.target.value)} + disabled={isViewMode} + /> +
+
+ +
+
+ + handleFieldChange("dueDate", e.target.value)} + disabled={isViewMode} + /> +
+
+ +