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/[GUIDE] collaboration-with-claude.md b/claudedocs/[GUIDE] collaboration-with-claude.md index fe634187..6f85bccc 100644 --- a/claudedocs/[GUIDE] collaboration-with-claude.md +++ b/claudedocs/[GUIDE] collaboration-with-claude.md @@ -93,4 +93,37 @@ --- -*2025-11-27 작성* +## 공통 UI 컴포넌트 사용 규칙 + +### 로딩 스피너 + +**필수**: 로딩 상태 표시 시 반드시 공통 스피너 컴포넌트 사용 + +```tsx +import { + ContentLoadingSpinner, + PageLoadingSpinner, + TableLoadingSpinner, + ButtonSpinner +} from '@/components/ui/loading-spinner'; +``` + +| 컴포넌트 | 용도 | 예시 | +|----------|------|------| +| `ContentLoadingSpinner` | 상세/수정 페이지 컨텐츠 영역 | `if (isLoading) return ;` | +| `PageLoadingSpinner` | 페이지 전환, 전체 페이지 | loading.tsx, 초기 로딩 | +| `TableLoadingSpinner` | 테이블/리스트 영역 | 데이터 테이블 로딩 | +| `ButtonSpinner` | 버튼 내부 (저장 중 등) | `{isSaving && }` | + +**금지 패턴:** +```tsx +// ❌ 텍스트만 사용 금지 +
로딩 중...
+ +// ❌ 직접 스피너 구현 금지 +
+``` + +--- + +*2025-11-27 작성 / 2026-01-12 스피너 규칙 추가* 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] project-detail-checklist.md b/claudedocs/[IMPL-2026-01-12] project-detail-checklist.md new file mode 100644 index 00000000..842d86dc --- /dev/null +++ b/claudedocs/[IMPL-2026-01-12] project-detail-checklist.md @@ -0,0 +1,52 @@ +# 프로젝트 실행관리 상세 페이지 구현 체크리스트 + +## 구현 일자: 2026-01-12 + +## 페이지 구조 +- 페이지 경로: `/construction/project/management/[id]` +- 칸반 보드 형태의 상세 페이지 +- 프로젝트 → 단계 → 상세 연동 + +--- + +## 작업 목록 + +### 1. 타입 및 데이터 준비 +- [x] types.ts - 상세 페이지용 타입 추가 (Stage, StageDetail, ProjectDetail 등) +- [x] actions.ts - 상세 페이지 목업 데이터 추가 + +### 2. 칸반 보드 컴포넌트 +- [x] ProjectKanbanBoard.tsx - 칸반 보드 컨테이너 +- [x] KanbanColumn.tsx - 칸반 컬럼 공통 컴포넌트 +- [x] ProjectCard.tsx - 프로젝트 카드 (진행률, 계약금, 기간) +- [x] StageCard.tsx - 단계 카드 (입찰/계약/시공) +- [x] DetailCard.tsx - 상세 카드 (현장설명회 등 단순 목록) + +### 3. 프로젝트 종료 팝업 +- [x] ProjectEndDialog.tsx - 프로젝트 종료 다이얼로그 + +### 4. 메인 페이지 조립 +- [x] ProjectDetailClient.tsx - 메인 클라이언트 컴포넌트 +- [x] page.tsx - 상세 페이지 진입점 + +### 5. 검증 +- [ ] 칸반 보드 동작 확인 (프로젝트→단계→상세 연동) +- [ ] 프로젝트 종료 팝업 동작 확인 +- [ ] 리스트 페이지에서 상세 페이지 이동 확인 + +--- + +## 참고 사항 +- 1차 구현: 상세 하위 목록 없는 경우 (현장설명회) 먼저 구현 +- 이후 추가로 보면서 맞춰가기 +- 기존 리스트 페이지 패턴 참고 + +--- + +## 진행 상황 +- 시작: 2026-01-12 +- 현재 상태: 1차 구현 완료, 브라우저 검증 대기 + +## 테스트 URL +- 리스트 페이지: http://localhost:3000/ko/construction/project/management +- 상세 페이지: http://localhost:3000/ko/construction/project/management/1 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/[IMPL-2026-01-13] mobile-filter-migration-checklist.md b/claudedocs/[IMPL-2026-01-13] mobile-filter-migration-checklist.md new file mode 100644 index 00000000..978500fd --- /dev/null +++ b/claudedocs/[IMPL-2026-01-13] mobile-filter-migration-checklist.md @@ -0,0 +1,181 @@ +# 모바일 필터 공통화 마이그레이션 체크리스트 + +> **작업 내용**: `IntegratedListTemplateV2` 사용 페이지에 `filterConfig` 방식 모바일 필터 적용 +> **시작일**: 2026-01-13 +> **완료 기준**: 모든 테이블 리스트 페이지에서 모바일 바텀시트 필터가 정상 동작 + +--- + +## ✅ 이미 완료된 페이지 (6개) + +- [x] 발주관리 (`OrderManagementListClient.tsx`) - filterConfig 방식 +- [x] 기성청구관리 (`ProgressBillingManagementListClient.tsx`) - filterConfig 방식 +- [x] 공과관리 (`UtilityManagementListClient.tsx`) - filterConfig 방식 +- [x] 시공관리 (`ConstructionManagementListClient.tsx`) - filterConfig 방식 ✨변경 +- [x] 거래처관리 (`PartnerListClient.tsx`) - filterConfig 방식 ✨신규 + +--- + +## 🏗️ 건설 도메인 (12개) ✅ 완료 + +### 입찰관리 +- [x] 현장설명회관리 (`SiteBriefingListClient.tsx`) +- [x] 견적관리 (`EstimateListClient.tsx`) +- [x] 입찰관리 (`BiddingListClient.tsx`) + +### 계약관리 +- [x] 계약관리 (`ContractListClient.tsx`) +- [x] 인수인계보고서 (`HandoverReportListClient.tsx`) + +### 발주관리 +- [x] 현장관리 (`SiteManagementListClient.tsx`) +- [x] 구조검토관리 (`StructureReviewListClient.tsx`) + +### 공사관리 +- [x] 이슈관리 (`IssueManagementListClient.tsx`) +- [x] 작업인력현황 (`WorkerStatusListClient.tsx`) + +### 기준정보 +- [x] 품목관리 (`ItemManagementClient.tsx`) +- [x] 단가관리 (`PricingListClient.tsx`) +- [x] 노임관리 (`LaborManagementClient.tsx`) + +--- + +## 👥 HR 도메인 (5개) + +- [ ] 급여관리 (`hr/SalaryManagement/index.tsx`) +- [ ] 사원관리 (`hr/EmployeeManagement/index.tsx`) +- [ ] 휴가관리 (`hr/VacationManagement/index.tsx`) +- [ ] 근태관리 (`hr/AttendanceManagement/index.tsx`) +- [ ] 카드관리 (`hr/CardManagement/index.tsx`) + +--- + +## 💰 회계 도메인 (14개) + +- [ ] 거래처관리 (`accounting/VendorManagement/index.tsx`) +- [ ] 매입관리 (`accounting/PurchaseManagement/index.tsx`) +- [ ] 매출관리 (`accounting/SalesManagement/index.tsx`) +- [ ] 입금관리 (`accounting/DepositManagement/index.tsx`) +- [ ] 출금관리 (`accounting/WithdrawalManagement/index.tsx`) +- [ ] 어음관리 (`accounting/BillManagement/index.tsx`) +- [ ] 거래처원장 (`accounting/VendorLedger/index.tsx`) +- [ ] 지출예상내역서 (`accounting/ExpectedExpenseManagement/index.tsx`) +- [ ] 입출금계좌조회 (`accounting/BankTransactionInquiry/index.tsx`) +- [ ] 카드내역조회 (`accounting/CardTransactionInquiry/index.tsx`) +- [ ] 악성채권추심 (`accounting/BadDebtCollection/index.tsx`) + +--- + +## 📦 생산/자재/품질/출고 도메인 (6개) + +- [ ] 작업지시관리 (`production/WorkOrders/WorkOrderList.tsx`) +- [ ] 작업실적조회 (`production/WorkResults/WorkResultList.tsx`) +- [ ] 재고현황 (`material/StockStatus/StockStatusList.tsx`) +- [ ] 입고관리 (`material/ReceivingManagement/ReceivingList.tsx`) +- [ ] 검사관리 (`quality/InspectionManagement/InspectionList.tsx`) +- [ ] 출하관리 (`outbound/ShipmentManagement/ShipmentList.tsx`) + +--- + +## 📝 전자결재 도메인 (3개) + +- [ ] 기안함 (`approval/DraftBox/index.tsx`) +- [ ] 결재함 (`approval/ApprovalBox/index.tsx`) +- [ ] 참조함 (`approval/ReferenceBox/index.tsx`) + +--- + +## ⚙️ 설정 도메인 (4개) + +- [ ] 계좌관리 (`settings/AccountManagement/index.tsx`) +- [ ] 팝업관리 (`settings/PopupManagement/PopupList.tsx`) +- [ ] 결제내역 (`settings/PaymentHistoryManagement/index.tsx`) +- [ ] 권한관리 (`settings/PermissionManagement/index.tsx`) + +--- + +## 📋 기타 도메인 (9개) + +- [ ] 품목기준관리 (`items/ItemListClient.tsx`) +- [ ] 견적관리 (`quotes/QuoteManagementClient.tsx`) +- [ ] 단가관리-일반 (`pricing/PricingListClient.tsx`) +- [ ] 공정관리 (`process-management/ProcessListClient.tsx`) +- [ ] 게시판목록 (`board/BoardList/index.tsx`) +- [ ] 게시판관리 (`board/BoardManagement/index.tsx`) +- [ ] 공지사항 (`customer-center/NoticeManagement/NoticeList.tsx`) +- [ ] 이벤트 (`customer-center/EventManagement/EventList.tsx`) +- [ ] 1:1문의 (`customer-center/InquiryManagement/InquiryList.tsx`) + +--- + +## 📊 진행 현황 + +| 도메인 | 완료 | 전체 | 진행률 | +|--------|------|------|--------| +| 건설 (기완료) | 6 | 6 | 100% | +| 건설 (마이그레이션) | 12 | 12 | 100% ✅ | +| HR | 0 | 5 | 0% | +| 회계 | 0 | 11 | 0% | +| 생산/자재/품질/출고 | 0 | 6 | 0% | +| 전자결재 | 0 | 3 | 0% | +| 설정 | 0 | 4 | 0% | +| 기타 | 0 | 9 | 0% | +| **총계** | **18** | **56** | **32%** | + +--- + +## 작업 방법 + +각 페이지에 다음 패턴으로 `filterConfig` 추가: + +```tsx +// 1. filterConfig 정의 +const filterConfig: FilterFieldConfig[] = useMemo(() => [ + { key: 'field1', label: '필드1', type: 'multi', options: field1Options }, + { key: 'field2', label: '필드2', type: 'single', options: field2Options }, +], [field1Options, field2Options]); + +// 2. filterValues 객체 +const filterValues: FilterValues = useMemo(() => ({ + field1: field1Filters, + field2: field2Filter, +}), [field1Filters, field2Filter]); + +// 3. handleFilterChange 함수 +const handleFilterChange = useCallback((key: string, value: string | string[]) => { + switch (key) { + case 'field1': setField1Filters(value as string[]); break; + case 'field2': setField2Filter(value as string); break; + } + setCurrentPage(1); +}, []); + +// 4. handleFilterReset 함수 +const handleFilterReset = useCallback(() => { + setField1Filters([]); + setField2Filter('all'); + setCurrentPage(1); +}, []); + +// 5. IntegratedListTemplateV2에 props 전달 + +``` + +--- + +## 변경 이력 + +| 날짜 | 작업 내용 | +|------|----------| +| 2026-01-13 | 체크리스트 문서 생성, MobileFilter 스크롤 버그 수정 | +| 2026-01-13 | 시공관리 mobileFilterSlot → filterConfig 방식으로 변경, 협력업체관리 filterConfig 적용 | +| 2026-01-13 | 건설 도메인 12개 파일 마이그레이션 완료 (SiteBriefing, Estimate, Bidding, Contract, HandoverReport, SiteManagement, StructureReview, IssueManagement, WorkerStatus, ItemManagement, Pricing, LaborManagement) | 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..948aea0d 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 | 상태 | @@ -33,6 +34,19 @@ Last Updated: 2026-01-05 | **구조검토관리** | `/ko/construction/order/structure-review` | 🆕 NEW | | **발주관리** | `/ko/construction/order/order-management` | 🆕 NEW | +### 공사관리 (Construction) +| 페이지 | URL | 상태 | +|---|---|---| +| **시공관리** | `/ko/construction/project/construction-management` | ✅ 완료 | +| **이슈관리** | `/ko/construction/project/issue-management` | ✅ 완료 | +| **공과관리** | `/ko/construction/project/utility-management` | 🆕 NEW | +| **작업인력현황** | `/ko/construction/project/worker-status` | ✅ 완료 | + +### 기성청구관리 (Billing) +| 페이지 | URL | 상태 | +|---|---|---| +| **기성청구관리** | `/ko/construction/billing/progress-billing-management` | 🆕 NEW | + ### 기준정보 (Base Info) - 발주관리 하위 | 페이지 | URL | 상태 | |---|---|---| @@ -40,27 +54,3 @@ Last Updated: 2026-01-05 | **품목관리** | `/ko/construction/order/base-info/items` | 🆕 NEW | | **단가관리** | `/ko/construction/order/base-info/pricing` | 🆕 NEW | | **노임관리** | `/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/next.config.ts b/next.config.ts index 0774cffd..2b0991c0 100644 --- a/next.config.ts +++ b/next.config.ts @@ -6,6 +6,14 @@ const withNextIntl = createNextIntlPlugin('./src/i18n/request.ts'); const nextConfig: NextConfig = { reactStrictMode: true, // 🧪 TEST: Strict Mode 비활성화로 중복 요청 테스트 turbopack: {}, // ✅ CRITICAL: Next.js 15 + next-intl compatibility + images: { + remotePatterns: [ + { + protocol: 'https', + hostname: 'placehold.co', + }, + ], + }, experimental: { serverActions: { bodySizeLimit: '10mb', // 이미지 업로드를 위한 제한 증가 diff --git a/package-lock.json b/package-lock.json index 965cfeae..8d685752 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" }, @@ -4417,6 +4418,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", @@ -4801,6 +4811,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", @@ -4861,6 +4884,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", @@ -4888,6 +4920,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", @@ -6042,6 +6086,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", @@ -8847,6 +8900,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", @@ -9576,6 +9641,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", @@ -9586,6 +9669,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/billing/progress-billing-management/[id]/edit/page.tsx b/src/app/[locale]/(protected)/construction/billing/progress-billing-management/[id]/edit/page.tsx new file mode 100644 index 00000000..41cb4bcf --- /dev/null +++ b/src/app/[locale]/(protected)/construction/billing/progress-billing-management/[id]/edit/page.tsx @@ -0,0 +1,57 @@ +'use client'; + +import { use, useEffect, useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { ProgressBillingDetailForm } from '@/components/business/construction/progress-billing'; +import { getProgressBillingDetail } from '@/components/business/construction/progress-billing/actions'; + +interface ProgressBillingEditPageProps { + params: Promise<{ id: string }>; +} + +export default function ProgressBillingEditPage({ params }: ProgressBillingEditPageProps) { + const { id } = use(params); + const router = useRouter(); + const [data, setData] = useState>['data']>(undefined); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + getProgressBillingDetail(id) + .then(result => { + if (result.success && result.data) { + setData(result.data); + } else { + setError('기성청구 정보를 찾을 수 없습니다.'); + } + }) + .catch(() => { + setError('기성청구 정보를 불러오는 중 오류가 발생했습니다.'); + }) + .finally(() => setIsLoading(false)); + }, [id]); + + if (isLoading) { + return ( +
+
로딩 중...
+
+ ); + } + + if (error || !data) { + return ( +
+
{error || '기성청구 정보를 찾을 수 없습니다.'}
+ +
+ ); + } + + return ; +} diff --git a/src/app/[locale]/(protected)/construction/billing/progress-billing-management/[id]/page.tsx b/src/app/[locale]/(protected)/construction/billing/progress-billing-management/[id]/page.tsx new file mode 100644 index 00000000..596afd43 --- /dev/null +++ b/src/app/[locale]/(protected)/construction/billing/progress-billing-management/[id]/page.tsx @@ -0,0 +1,57 @@ +'use client'; + +import { use, useEffect, useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { ProgressBillingDetailForm } from '@/components/business/construction/progress-billing'; +import { getProgressBillingDetail } from '@/components/business/construction/progress-billing/actions'; + +interface ProgressBillingDetailPageProps { + params: Promise<{ id: string }>; +} + +export default function ProgressBillingDetailPage({ params }: ProgressBillingDetailPageProps) { + const { id } = use(params); + const router = useRouter(); + const [data, setData] = useState>['data']>(undefined); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + getProgressBillingDetail(id) + .then(result => { + if (result.success && result.data) { + setData(result.data); + } else { + setError('기성청구 정보를 찾을 수 없습니다.'); + } + }) + .catch(() => { + setError('기성청구 정보를 불러오는 중 오류가 발생했습니다.'); + }) + .finally(() => setIsLoading(false)); + }, [id]); + + if (isLoading) { + return ( +
+
로딩 중...
+
+ ); + } + + if (error || !data) { + return ( +
+
{error || '기성청구 정보를 찾을 수 없습니다.'}
+ +
+ ); + } + + return ; +} diff --git a/src/app/[locale]/(protected)/construction/billing/progress-billing-management/page.tsx b/src/app/[locale]/(protected)/construction/billing/progress-billing-management/page.tsx new file mode 100644 index 00000000..6c704ace --- /dev/null +++ b/src/app/[locale]/(protected)/construction/billing/progress-billing-management/page.tsx @@ -0,0 +1,38 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import ProgressBillingManagementListClient from '@/components/business/construction/progress-billing/ProgressBillingManagementListClient'; +import { getProgressBillingList, getProgressBillingStats } from '@/components/business/construction/progress-billing/actions'; +import type { ProgressBilling, ProgressBillingStats } from '@/components/business/construction/progress-billing/types'; + +export default function ProgressBillingManagementPage() { + const [data, setData] = useState([]); + const [stats, setStats] = useState(undefined); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + Promise.all([ + getProgressBillingList({ size: 1000 }), + getProgressBillingStats(), + ]) + .then(([listResult, statsResult]) => { + if (listResult.success && listResult.data) { + setData(listResult.data.items); + } + if (statsResult.success && statsResult.data) { + setStats(statsResult.data); + } + }) + .finally(() => setIsLoading(false)); + }, []); + + if (isLoading) { + return ( +
+
로딩 중...
+
+ ); + } + + return ; +} \ No newline at end of file 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 815b3c85..ec5a3f80 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 @@ -9,6 +9,190 @@ interface EstimateEditPageProps { params: Promise<{ id: string }>; } +// 목업 데이터 - 추후 API 연동 +function getEstimateDetail(id: string): EstimateDetail { + // TODO: 실제 API 연동 + const mockData: EstimateDetail = { + id, + estimateCode: '123123', + partnerId: '1', + partnerName: '회사명', + projectName: '현장명', + estimatorId: 'hong', + estimatorName: '이름', + estimateCompanyManager: '홍길동', + estimateCompanyManagerContact: '01012341234', + itemCount: 21, + estimateAmount: 1420000, + completedDate: null, + bidDate: '2025-12-12', + status: 'pending', + createdAt: '2025-12-01', + updatedAt: '2025-12-01', + createdBy: 'hong', + siteBriefing: { + briefingCode: '123123', + partnerName: '회사명', + companyName: '회사명', + briefingDate: '2025-12-12', + attendee: '이름', + }, + bidInfo: { + projectName: '현장명', + bidDate: '2025-12-12', + siteCount: 21, + constructionPeriod: '2026-01-01 ~ 2026-12-10', + constructionStartDate: '2026-01-01', + constructionEndDate: '2026-12-10', + vatType: 'excluded', + workReport: '업무 보고 내용', + documents: [ + { + id: '1', + fileName: 'abc.zip', + fileUrl: '#', + fileSize: 1024000, + }, + ], + }, + summaryItems: [ + { + id: '1', + name: '서터 심창측공사', + quantity: 1, + unit: '식', + materialCost: 78540000, + laborCost: 15410000, + totalCost: 93950000, + remarks: '', + }, + ], + expenseItems: [ + { + id: '1', + name: 'public_1', + amount: 10000, + }, + ], + priceAdjustments: [ + { + id: '1', + category: '배합비', + unitPrice: 10000, + coating: 10000, + batting: 10000, + boxReinforce: 10500, + painting: 10500, + total: 51000, + }, + { + id: '2', + category: '재단비', + unitPrice: 1375, + coating: 0, + batting: 0, + boxReinforce: 0, + painting: 0, + total: 1375, + }, + { + id: '3', + category: '판매단가', + unitPrice: 0, + coating: 10000, + batting: 10000, + boxReinforce: 10500, + painting: 10500, + total: 41000, + }, + { + id: '4', + category: '조립단가', + unitPrice: 10300, + coating: 10300, + batting: 10300, + boxReinforce: 10500, + painting: 10200, + total: 51600, + }, + ], + detailItems: [ + { + id: '1', + no: 1, + name: 'FS530외/주차', + material: 'screen', + width: 2350, + height: 2500, + quantity: 1, + box: 1, + assembly: 0, + coating: 0, + batting: 0, + mounting: 0, + fitting: 0, + controller: 0, + widthConstruction: 0, + heightConstruction: 0, + materialCost: 1420000, + laborCost: 510000, + quantityPrice: 1930000, + expenseQuantity: 5500, + expenseTotal: 5500, + totalCost: 1930000, + otherCost: 0, + marginCost: 0, + totalPrice: 1930000, + unitPrice: 1420000, + expense: 0, + marginRate: 0, + unitQuantity: 1, + expenseResult: 0, + marginActual: 0, + }, + { + id: '2', + no: 2, + name: 'FS530외/주차', + material: 'screen', + width: 7500, + height: 2500, + quantity: 1, + box: 1, + assembly: 0, + coating: 0, + batting: 0, + mounting: 0, + fitting: 0, + controller: 0, + widthConstruction: 0, + heightConstruction: 0, + materialCost: 4720000, + laborCost: 780000, + quantityPrice: 5500000, + expenseQuantity: 5500, + expenseTotal: 5500, + totalCost: 5500000, + otherCost: 0, + marginCost: 0, + totalPrice: 5500000, + unitPrice: 4720000, + expense: 0, + marginRate: 0, + unitQuantity: 1, + expenseResult: 0, + marginActual: 0, + }, + ], + approval: { + approvers: [], + references: [], + }, + }; + + return mockData; +} + export default function EstimateEditPage({ params }: EstimateEditPageProps) { const { id } = use(params); const [data, setData] = useState(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 4778e319..5e392531 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 @@ -9,6 +9,190 @@ interface EstimateDetailPageProps { params: Promise<{ id: string }>; } +// 목업 데이터 - 추후 API 연동 +function getEstimateDetail(id: string): EstimateDetail { + // TODO: 실제 API 연동 + const mockData: EstimateDetail = { + id, + estimateCode: '123123', + partnerId: '1', + partnerName: '회사명', + projectName: '현장명', + estimatorId: 'hong', + estimatorName: '이름', + estimateCompanyManager: '홍길동', + estimateCompanyManagerContact: '01012341234', + itemCount: 21, + estimateAmount: 1420000, + completedDate: null, + bidDate: '2025-12-12', + status: 'pending', + createdAt: '2025-12-01', + updatedAt: '2025-12-01', + createdBy: 'hong', + siteBriefing: { + briefingCode: '123123', + partnerName: '회사명', + companyName: '회사명', + briefingDate: '2025-12-12', + attendee: '이름', + }, + bidInfo: { + projectName: '현장명', + bidDate: '2025-12-12', + siteCount: 21, + constructionPeriod: '2026-01-01 ~ 2026-12-10', + constructionStartDate: '2026-01-01', + constructionEndDate: '2026-12-10', + vatType: 'excluded', + workReport: '업무 보고 내용', + documents: [ + { + id: '1', + fileName: 'abc.zip', + fileUrl: '#', + fileSize: 1024000, + }, + ], + }, + summaryItems: [ + { + id: '1', + name: '서터 심창측공사', + quantity: 1, + unit: '식', + materialCost: 78540000, + laborCost: 15410000, + totalCost: 93950000, + remarks: '', + }, + ], + expenseItems: [ + { + id: '1', + name: 'public_1', + amount: 10000, + }, + ], + priceAdjustments: [ + { + id: '1', + category: '배합비', + unitPrice: 10000, + coating: 10000, + batting: 10000, + boxReinforce: 10500, + painting: 10500, + total: 51000, + }, + { + id: '2', + category: '재단비', + unitPrice: 1375, + coating: 0, + batting: 0, + boxReinforce: 0, + painting: 0, + total: 1375, + }, + { + id: '3', + category: '판매단가', + unitPrice: 0, + coating: 10000, + batting: 10000, + boxReinforce: 10500, + painting: 10500, + total: 41000, + }, + { + id: '4', + category: '조립단가', + unitPrice: 10300, + coating: 10300, + batting: 10300, + boxReinforce: 10500, + painting: 10200, + total: 51600, + }, + ], + detailItems: [ + { + id: '1', + no: 1, + name: 'FS530외/주차', + material: 'screen', + width: 2350, + height: 2500, + quantity: 1, + box: 1, + assembly: 0, + coating: 0, + batting: 0, + mounting: 0, + fitting: 0, + controller: 0, + widthConstruction: 0, + heightConstruction: 0, + materialCost: 1420000, + laborCost: 510000, + quantityPrice: 1930000, + expenseQuantity: 5500, + expenseTotal: 5500, + totalCost: 1930000, + otherCost: 0, + marginCost: 0, + totalPrice: 1930000, + unitPrice: 1420000, + expense: 0, + marginRate: 0, + unitQuantity: 1, + expenseResult: 0, + marginActual: 0, + }, + { + id: '2', + no: 2, + name: 'FS530외/주차', + material: 'screen', + width: 7500, + height: 2500, + quantity: 1, + box: 1, + assembly: 0, + coating: 0, + batting: 0, + mounting: 0, + fitting: 0, + controller: 0, + widthConstruction: 0, + heightConstruction: 0, + materialCost: 4720000, + laborCost: 780000, + quantityPrice: 5500000, + expenseQuantity: 5500, + expenseTotal: 5500, + totalCost: 5500000, + otherCost: 0, + marginCost: 0, + totalPrice: 5500000, + unitPrice: 4720000, + expense: 0, + marginRate: 0, + unitQuantity: 1, + expenseResult: 0, + marginActual: 0, + }, + ], + approval: { + approvers: [], + references: [], + }, + }; + + return mockData; +} + export default function EstimateDetailPage({ params }: EstimateDetailPageProps) { const { id } = use(params); const [data, setData] = useState(null); diff --git a/src/app/[locale]/(protected)/construction/project/construction-management/[id]/edit/page.tsx b/src/app/[locale]/(protected)/construction/project/construction-management/[id]/edit/page.tsx new file mode 100644 index 00000000..8b8285e9 --- /dev/null +++ b/src/app/[locale]/(protected)/construction/project/construction-management/[id]/edit/page.tsx @@ -0,0 +1,16 @@ +'use client'; + +import { use } from 'react'; +import ConstructionDetailClient from '@/components/business/construction/management/ConstructionDetailClient'; + +interface PageProps { + params: Promise<{ + id: string; + }>; +} + +export default function ConstructionManagementEditPage({ params }: PageProps) { + const { id } = use(params); + + return ; +} \ No newline at end of file diff --git a/src/app/[locale]/(protected)/construction/project/construction-management/[id]/page.tsx b/src/app/[locale]/(protected)/construction/project/construction-management/[id]/page.tsx new file mode 100644 index 00000000..86244c16 --- /dev/null +++ b/src/app/[locale]/(protected)/construction/project/construction-management/[id]/page.tsx @@ -0,0 +1,16 @@ +'use client'; + +import { use } from 'react'; +import ConstructionDetailClient from '@/components/business/construction/management/ConstructionDetailClient'; + +interface PageProps { + params: Promise<{ + id: string; + }>; +} + +export default function ConstructionManagementDetailPage({ params }: PageProps) { + const { id } = use(params); + + return ; +} diff --git a/src/app/[locale]/(protected)/construction/project/construction-management/page.tsx b/src/app/[locale]/(protected)/construction/project/construction-management/page.tsx new file mode 100644 index 00000000..c8dd33b2 --- /dev/null +++ b/src/app/[locale]/(protected)/construction/project/construction-management/page.tsx @@ -0,0 +1,52 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import ConstructionManagementListClient from '@/components/business/construction/management/ConstructionManagementListClient'; +import { + getConstructionManagementList, + getConstructionManagementStats, +} from '@/components/business/construction/management/actions'; +import type { + ConstructionManagement, + ConstructionManagementStats, +} from '@/components/business/construction/management/types'; + +export default function ConstructionManagementPage() { + const [data, setData] = useState([]); + const [stats, setStats] = useState(undefined); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + const loadData = async () => { + try { + const [listResult, statsResult] = await Promise.all([ + getConstructionManagementList({ size: 1000 }), + getConstructionManagementStats(), + ]); + + if (listResult.success && listResult.data) { + setData(listResult.data.items); + } + if (statsResult.success && statsResult.data) { + setStats(statsResult.data); + } + } catch (error) { + console.error('Failed to load construction management data:', error); + } finally { + setIsLoading(false); + } + }; + + loadData(); + }, []); + + if (isLoading) { + return ( +
+
로딩 중...
+
+ ); + } + + return ; +} \ No newline at end of file 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/issue-management/[id]/edit/page.tsx b/src/app/[locale]/(protected)/construction/project/issue-management/[id]/edit/page.tsx new file mode 100644 index 00000000..3b06bdca --- /dev/null +++ b/src/app/[locale]/(protected)/construction/project/issue-management/[id]/edit/page.tsx @@ -0,0 +1,53 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { useParams } from 'next/navigation'; +import IssueDetailForm from '@/components/business/construction/issue-management/IssueDetailForm'; +import { getIssue } from '@/components/business/construction/issue-management/actions'; +import type { Issue } from '@/components/business/construction/issue-management/types'; + +export default function IssueEditPage() { + const params = useParams(); + const id = params.id as string; + + const [issue, setIssue] = useState(undefined); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + const loadData = async () => { + try { + const result = await getIssue(id); + if (result.success && result.data) { + setIssue(result.data); + } else { + setError(result.error || '이슈를 찾을 수 없습니다.'); + } + } catch { + setError('이슈 조회에 실패했습니다.'); + } finally { + setIsLoading(false); + } + }; + + loadData(); + }, [id]); + + if (isLoading) { + return ( +
+
로딩 중...
+
+ ); + } + + if (error) { + return ( +
+
{error}
+
+ ); + } + + return ; +} \ No newline at end of file diff --git a/src/app/[locale]/(protected)/construction/project/issue-management/[id]/page.tsx b/src/app/[locale]/(protected)/construction/project/issue-management/[id]/page.tsx new file mode 100644 index 00000000..79138f40 --- /dev/null +++ b/src/app/[locale]/(protected)/construction/project/issue-management/[id]/page.tsx @@ -0,0 +1,53 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { useParams } from 'next/navigation'; +import IssueDetailForm from '@/components/business/construction/issue-management/IssueDetailForm'; +import { getIssue } from '@/components/business/construction/issue-management/actions'; +import type { Issue } from '@/components/business/construction/issue-management/types'; + +export default function IssueDetailPage() { + const params = useParams(); + const id = params.id as string; + + const [issue, setIssue] = useState(undefined); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + const loadData = async () => { + try { + const result = await getIssue(id); + if (result.success && result.data) { + setIssue(result.data); + } else { + setError(result.error || '이슈를 찾을 수 없습니다.'); + } + } catch { + setError('이슈 조회에 실패했습니다.'); + } finally { + setIsLoading(false); + } + }; + + loadData(); + }, [id]); + + if (isLoading) { + return ( +
+
로딩 중...
+
+ ); + } + + if (error) { + return ( +
+
{error}
+
+ ); + } + + return ; +} \ No newline at end of file diff --git a/src/app/[locale]/(protected)/construction/project/issue-management/new/page.tsx b/src/app/[locale]/(protected)/construction/project/issue-management/new/page.tsx new file mode 100644 index 00000000..349cc58f --- /dev/null +++ b/src/app/[locale]/(protected)/construction/project/issue-management/new/page.tsx @@ -0,0 +1,7 @@ +'use client'; + +import IssueDetailForm from '@/components/business/construction/issue-management/IssueDetailForm'; + +export default function IssueNewPage() { + return ; +} \ No newline at end of file diff --git a/src/app/[locale]/(protected)/construction/project/issue-management/page.tsx b/src/app/[locale]/(protected)/construction/project/issue-management/page.tsx new file mode 100644 index 00000000..1c5fed8d --- /dev/null +++ b/src/app/[locale]/(protected)/construction/project/issue-management/page.tsx @@ -0,0 +1,52 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import IssueManagementListClient from '@/components/business/construction/issue-management/IssueManagementListClient'; +import { + getIssueList, + getIssueStats, +} from '@/components/business/construction/issue-management/actions'; +import type { + Issue, + IssueStats, +} from '@/components/business/construction/issue-management/types'; + +export default function IssueManagementPage() { + const [data, setData] = useState([]); + const [stats, setStats] = useState(undefined); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + const loadData = async () => { + try { + const [listResult, statsResult] = await Promise.all([ + getIssueList({ size: 1000 }), + getIssueStats(), + ]); + + if (listResult.success && listResult.data) { + setData(listResult.data.items); + } + if (statsResult.success && statsResult.data) { + setStats(statsResult.data); + } + } catch (error) { + console.error('Failed to load issue management data:', error); + } finally { + setIsLoading(false); + } + }; + + loadData(); + }, []); + + if (isLoading) { + return ( +
+
로딩 중...
+
+ ); + } + + return ; +} diff --git a/src/app/[locale]/(protected)/construction/project/management/[id]/page.tsx b/src/app/[locale]/(protected)/construction/project/management/[id]/page.tsx new file mode 100644 index 00000000..17601b37 --- /dev/null +++ b/src/app/[locale]/(protected)/construction/project/management/[id]/page.tsx @@ -0,0 +1,17 @@ +'use client'; + +import { use } from 'react'; +import ProjectDetailClient from '@/components/business/construction/management/ProjectDetailClient'; + +interface PageProps { + params: Promise<{ + id: string; + locale: string; + }>; +} + +export default function ProjectDetailPage({ params }: PageProps) { + const { id } = use(params); + + return ; +} 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)/construction/project/utility-management/page.tsx b/src/app/[locale]/(protected)/construction/project/utility-management/page.tsx new file mode 100644 index 00000000..cf94741d --- /dev/null +++ b/src/app/[locale]/(protected)/construction/project/utility-management/page.tsx @@ -0,0 +1,5 @@ +import { UtilityManagementListClient } from '@/components/business/construction/utility-management'; + +export default function UtilityManagementPage() { + return ; +} \ No newline at end of file diff --git a/src/app/[locale]/(protected)/construction/project/worker-status/page.tsx b/src/app/[locale]/(protected)/construction/project/worker-status/page.tsx new file mode 100644 index 00000000..6584edcf --- /dev/null +++ b/src/app/[locale]/(protected)/construction/project/worker-status/page.tsx @@ -0,0 +1,32 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import WorkerStatusListClient from '@/components/business/construction/worker-status/WorkerStatusListClient'; +import { getWorkerStatusList, getWorkerStatusStats } from '@/components/business/construction/worker-status/actions'; +import type { WorkerStatus, WorkerStatusStats } from '@/components/business/construction/worker-status/types'; +import { ContentLoadingSpinner } from '@/components/ui/loading-spinner'; + +export default function WorkerStatusPage() { + const [data, setData] = useState([]); + const [stats, setStats] = useState(undefined); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + Promise.all([getWorkerStatusList(), getWorkerStatusStats()]) + .then(([listResult, statsResult]) => { + if (listResult.success && listResult.data) { + setData(listResult.data.items); + } + if (statsResult.success && statsResult.data) { + setStats(statsResult.data); + } + }) + .finally(() => setIsLoading(false)); + }, []); + + if (isLoading) { + return ; + } + + return ; +} \ No newline at end of file diff --git a/src/app/[locale]/(protected)/hr/employee-management/new/page.tsx b/src/app/[locale]/(protected)/hr/employee-management/new/page.tsx index fcb8261b..fe9df044 100644 --- a/src/app/[locale]/(protected)/hr/employee-management/new/page.tsx +++ b/src/app/[locale]/(protected)/hr/employee-management/new/page.tsx @@ -1,22 +1,28 @@ 'use client'; -import { useRouter } from 'next/navigation'; +import { useRouter, useParams } from 'next/navigation'; +import { toast } from 'sonner'; import { EmployeeForm } from '@/components/hr/EmployeeManagement/EmployeeForm'; import { createEmployee } from '@/components/hr/EmployeeManagement/actions'; import type { EmployeeFormData } from '@/components/hr/EmployeeManagement/types'; export default function EmployeeNewPage() { const router = useRouter(); + const params = useParams(); + const locale = params.locale as string || 'ko'; const handleSave = async (data: EmployeeFormData) => { try { const result = await createEmployee(data); if (result.success) { - router.push('/ko/hr/employee-management'); + toast.success('사원이 등록되었습니다.'); + router.push(`/${locale}/hr/employee-management`); } else { + toast.error(result.error || '사원 등록에 실패했습니다.'); console.error('[EmployeeNewPage] Create failed:', result.error); } } catch (error) { + toast.error('서버 오류가 발생했습니다.'); console.error('[EmployeeNewPage] Create error:', error); } }; 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/app/globals.css b/src/app/globals.css index 47e6a63f..e462fa44 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -284,6 +284,25 @@ transition: all 0.2s cubic-bezier(0.25, 0.46, 0.45, 0.94); } + /* Bell ringing animation for notifications */ + @keyframes bell-ring { + 0%, 100% { transform: rotate(0deg); } + 10% { transform: rotate(14deg); } + 20% { transform: rotate(-12deg); } + 30% { transform: rotate(10deg); } + 40% { transform: rotate(-8deg); } + 50% { transform: rotate(6deg); } + 60% { transform: rotate(-4deg); } + 70% { transform: rotate(2deg); } + 80% { transform: rotate(-1deg); } + 90% { transform: rotate(0deg); } + } + + .animate-bell-ring { + animation: bell-ring 1s ease-in-out infinite; + transform-origin: top center; + } + /* Custom scrollbar */ ::-webkit-scrollbar { width: 8px; diff --git a/src/components/business/CEODashboard/CEODashboard.tsx b/src/components/business/CEODashboard/CEODashboard.tsx index 32b5f1e1..30bd1996 100644 --- a/src/components/business/CEODashboard/CEODashboard.tsx +++ b/src/components/business/CEODashboard/CEODashboard.tsx @@ -9,6 +9,7 @@ import { PageLayout } from '@/components/organisms/PageLayout'; import { PageHeader } from '@/components/organisms/PageHeader'; import { TodayIssueSection, + StatusBoardSection, DailyReportSection, MonthlyExpenseSection, CardManagementSection, @@ -214,12 +215,9 @@ export function CEODashboard() { />
- {/* 오늘의 이슈 */} - {dashboardSettings.todayIssue.enabled && ( - + {/* 오늘의 이슈 (새 리스트 형태) */} + {dashboardSettings.todayIssueList && ( + )} {/* 일일 일보 */} @@ -230,6 +228,14 @@ export function CEODashboard() { /> )} + {/* 현황판 (구 오늘의 이슈 - 카드 형태) */} + {(dashboardSettings.statusBoard?.enabled ?? dashboardSettings.todayIssue.enabled) && ( + + )} + {/* 당월 예상 지출 내역 */} {dashboardSettings.monthlyExpense && ( = { +// 현황판 항목 라벨 (구 오늘의 이슈) +const STATUS_BOARD_LABELS: Record = { orders: '수주', debtCollection: '채권 추심', safetyStock: '안전 재고', @@ -83,37 +83,67 @@ export function DashboardSettingsDialog({ })); }, []); - // 오늘의 이슈 전체 토글 - const handleTodayIssueToggle = useCallback((enabled: boolean) => { + // 오늘의 이슈 (리스트 형태) 토글 + const handleTodayIssueListToggle = useCallback((enabled: boolean) => { setLocalSettings((prev) => ({ ...prev, - todayIssue: { - ...prev.todayIssue, - enabled, - // 전체 OFF 시 개별 항목도 모두 OFF - items: enabled - ? prev.todayIssue.items - : Object.keys(prev.todayIssue.items).reduce( - (acc, key) => ({ ...acc, [key]: false }), - {} as TodayIssueSettings - ), - }, + todayIssueList: enabled, })); }, []); - // 오늘의 이슈 개별 항목 토글 - const handleTodayIssueItemToggle = useCallback( - (key: keyof TodayIssueSettings, enabled: boolean) => { - setLocalSettings((prev) => ({ + // 현황판 전체 토글 (구 오늘의 이슈) + const handleStatusBoardToggle = useCallback((enabled: boolean) => { + setLocalSettings((prev) => { + const statusBoardItems = prev.statusBoard?.items ?? prev.todayIssue.items; + return { ...prev, - todayIssue: { - ...prev.todayIssue, - items: { - ...prev.todayIssue.items, - [key]: enabled, - }, + statusBoard: { + enabled, + // 전체 OFF 시 개별 항목도 모두 OFF + items: enabled + ? statusBoardItems + : Object.keys(statusBoardItems).reduce( + (acc, key) => ({ ...acc, [key]: false }), + {} as TodayIssueSettings + ), }, - })); + // Legacy 호환성 유지 + todayIssue: { + enabled, + items: enabled + ? statusBoardItems + : Object.keys(statusBoardItems).reduce( + (acc, key) => ({ ...acc, [key]: false }), + {} as TodayIssueSettings + ), + }, + }; + }); + }, []); + + // 현황판 개별 항목 토글 + const handleStatusBoardItemToggle = useCallback( + (key: keyof TodayIssueSettings, enabled: boolean) => { + setLocalSettings((prev) => { + const statusBoardItems = prev.statusBoard?.items ?? prev.todayIssue.items; + const newItems = { + ...statusBoardItems, + [key]: enabled, + }; + return { + ...prev, + statusBoard: { + ...prev.statusBoard, + enabled: prev.statusBoard?.enabled ?? prev.todayIssue.enabled, + items: newItems, + }, + // Legacy 호환성 유지 + todayIssue: { + ...prev.todayIssue, + items: newItems, + }, + }; + }); }, [] ); @@ -280,30 +310,44 @@ export function DashboardSettingsDialog({
- {/* 오늘의 이슈 섹션 */} + {/* 오늘의 이슈 (리스트 형태) */} + + + {/* 일일 일보 */} + handleSectionToggle('dailyReport', checked)} + /> + + {/* 현황판 (구 오늘의 이슈 - 카드 형태) */}
- 오늘의 이슈 + 현황판
- {localSettings.todayIssue.enabled && ( + {(localSettings.statusBoard?.enabled ?? localSettings.todayIssue.enabled) && (
- {(Object.keys(TODAY_ISSUE_LABELS) as Array).map( + {(Object.keys(STATUS_BOARD_LABELS) as Array).map( (key) => (
- {TODAY_ISSUE_LABELS[key]} + {STATUS_BOARD_LABELS[key]} - handleTodayIssueItemToggle(key, checked) + handleStatusBoardItemToggle(key, checked) } />
@@ -313,13 +357,6 @@ export function DashboardSettingsDialog({ )}
- {/* 일일 일보 */} - handleSectionToggle('dailyReport', checked)} - /> - {/* 당월 예상 지출 내역 */} = { + '수주': 'orders', + '채권 추심': 'debtCollection', + '안전 재고': 'safetyStock', + '세금 신고': 'taxReport', + '신규 업체 등록': 'newVendor', + '연차': 'annualLeave', + '지각': 'lateness', + '결근': 'absence', + '발주': 'purchase', + '결재 요청': 'approvalRequest', +}; + +interface StatusBoardSectionProps { + items: TodayIssueItem[]; + itemSettings?: TodayIssueSettings; +} + +export function StatusBoardSection({ items, itemSettings }: StatusBoardSectionProps) { + const router = useRouter(); + + const handleItemClick = (path: string) => { + router.push(path); + }; + + // 설정에 따라 항목 필터링 + const filteredItems = itemSettings + ? items.filter((item) => { + const settingKey = LABEL_TO_SETTING_KEY[item.label]; + return settingKey ? itemSettings[settingKey] : true; + }) + : items; + + // 아이템 개수에 따른 동적 그리드 클래스 (xs: 344px Galaxy Fold 지원) + const getGridColsClass = () => { + const count = filteredItems.length; + if (count <= 1) return 'grid-cols-1'; + if (count === 2) return 'grid-cols-1 xs:grid-cols-2'; + if (count === 3) return 'grid-cols-1 xs:grid-cols-2 md:grid-cols-3'; + // 4개 이상: 최대 4열, 넘치면 아래로 + return 'grid-cols-1 xs:grid-cols-2 md:grid-cols-4'; + }; + + return ( + + + + +
+ {filteredItems.map((item) => ( + handleItemClick(item.path)} + icon={item.icon} + /> + ))} +
+
+
+ ); +} \ No newline at end of file diff --git a/src/components/business/CEODashboard/sections/TodayIssueSection.tsx b/src/components/business/CEODashboard/sections/TodayIssueSection.tsx index ba4678c6..042a1734 100644 --- a/src/components/business/CEODashboard/sections/TodayIssueSection.tsx +++ b/src/components/business/CEODashboard/sections/TodayIssueSection.tsx @@ -1,71 +1,168 @@ 'use client'; +import { useState } from 'react'; import { useRouter } from 'next/navigation'; import { Card, CardContent } from '@/components/ui/card'; -import { SectionTitle, IssueCardItem } from '../components'; -import type { TodayIssueItem, TodayIssueSettings } from '../types'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { toast } from 'sonner'; +import type { TodayIssueListItem, TodayIssueListBadgeType } from '../types'; -// 라벨 → 설정키 매핑 -const LABEL_TO_SETTING_KEY: Record = { - '수주': 'orders', - '채권 추심': 'debtCollection', - '안전 재고': 'safetyStock', - '세금 신고': 'taxReport', - '신규 업체 등록': 'newVendor', - '연차': 'annualLeave', - '지각': 'lateness', - '결근': 'absence', - '발주': 'purchase', - '결재 요청': 'approvalRequest', +// 뱃지 색상 매핑 +const BADGE_COLORS: Record = { + '수주 성공': 'bg-blue-100 text-blue-700 hover:bg-blue-100', + '주식 이슈': 'bg-purple-100 text-purple-700 hover:bg-purple-100', + '직정 제고': 'bg-orange-100 text-orange-700 hover:bg-orange-100', + '지출예상내역서': 'bg-green-100 text-green-700 hover:bg-green-100', + '세금 신고': 'bg-red-100 text-red-700 hover:bg-red-100', + '결재 요청': 'bg-yellow-100 text-yellow-700 hover:bg-yellow-100', + '기타': 'bg-gray-100 text-gray-700 hover:bg-gray-100', }; +// 필터 옵션 +const FILTER_OPTIONS = [ + { value: 'all', label: '전체' }, + { value: '수주 성공', label: '수주 성공' }, + { value: '주식 이슈', label: '주식 이슈' }, + { value: '직정 제고', label: '직정 제고' }, + { value: '지출예상내역서', label: '지출예상내역서' }, + { value: '세금 신고', label: '세금 신고' }, + { value: '결재 요청', label: '결재 요청' }, +]; + interface TodayIssueSectionProps { - items: TodayIssueItem[]; - itemSettings?: TodayIssueSettings; + items: TodayIssueListItem[]; } -export function TodayIssueSection({ items, itemSettings }: TodayIssueSectionProps) { +export function TodayIssueSection({ items }: TodayIssueSectionProps) { const router = useRouter(); + const [filter, setFilter] = useState('all'); + const [dismissedIds, setDismissedIds] = useState>(new Set()); - const handleItemClick = (path: string) => { - router.push(path); + // 확인되지 않은 아이템만 필터링 + const activeItems = items.filter((item) => !dismissedIds.has(item.id)); + + // 필터링된 아이템 + const filteredItems = filter === 'all' + ? activeItems + : activeItems.filter((item) => item.badge === filter); + + // 아이템 클릭 + const handleItemClick = (item: TodayIssueListItem) => { + if (item.path) { + router.push(item.path); + } }; - // 설정에 따라 항목 필터링 - const filteredItems = itemSettings - ? items.filter((item) => { - const settingKey = LABEL_TO_SETTING_KEY[item.label]; - return settingKey ? itemSettings[settingKey] : true; - }) - : items; + // 확인 버튼 클릭 (목록에서 제거) + const handleDismiss = (item: TodayIssueListItem) => { + setDismissedIds((prev) => new Set(prev).add(item.id)); + toast.success(`"${item.content}" 확인 완료`); + }; - // 아이템 개수에 따른 동적 그리드 클래스 (xs: 344px Galaxy Fold 지원) - const getGridColsClass = () => { - const count = filteredItems.length; - if (count <= 1) return 'grid-cols-1'; - if (count === 2) return 'grid-cols-1 xs:grid-cols-2'; - if (count === 3) return 'grid-cols-1 xs:grid-cols-2 md:grid-cols-3'; - // 4개 이상: 최대 4열, 넘치면 아래로 - return 'grid-cols-1 xs:grid-cols-2 md:grid-cols-4'; + // 승인 버튼 클릭 + const handleApprove = (item: TodayIssueListItem) => { + setDismissedIds((prev) => new Set(prev).add(item.id)); + toast.success(`"${item.content}" 승인 처리되었습니다.`); + }; + + // 반려 버튼 클릭 + const handleReject = (item: TodayIssueListItem) => { + setDismissedIds((prev) => new Set(prev).add(item.id)); + toast.error(`"${item.content}" 반려 처리되었습니다.`); }; return ( - + {/* 헤더 */} +
+

오늘의 이슈

+ +
-
- {filteredItems.map((item) => ( - handleItemClick(item.path)} - icon={item.icon} - /> - ))} + {/* 리스트 */} +
+ {filteredItems.length === 0 ? ( +
+ 표시할 이슈가 없습니다. +
+ ) : ( + filteredItems.map((item) => ( +
handleItemClick(item)} + > + {/* 좌측: 뱃지 + 내용 */} +
+ + {item.badge} + + + {item.content} + +
+ + {/* 우측: 시간 + 버튼 */} +
e.stopPropagation()}> + + {item.time} + + {item.needsApproval ? ( +
+ + +
+ ) : ( + + )} +
+
+ )) + )}
diff --git a/src/components/business/CEODashboard/sections/index.ts b/src/components/business/CEODashboard/sections/index.ts index 1d9bee07..a73a33d8 100644 --- a/src/components/business/CEODashboard/sections/index.ts +++ b/src/components/business/CEODashboard/sections/index.ts @@ -1,4 +1,5 @@ export { TodayIssueSection } from './TodayIssueSection'; +export { StatusBoardSection } from './StatusBoardSection'; export { DailyReportSection } from './DailyReportSection'; export { MonthlyExpenseSection } from './MonthlyExpenseSection'; export { CardManagementSection } from './CardManagementSection'; diff --git a/src/components/business/CEODashboard/types.ts b/src/components/business/CEODashboard/types.ts index b6852c2b..6fe79857 100644 --- a/src/components/business/CEODashboard/types.ts +++ b/src/components/business/CEODashboard/types.ts @@ -45,7 +45,7 @@ export interface AmountCard { isHighlighted?: boolean; // 빨간색 강조 } -// 오늘의 이슈 항목 +// 오늘의 이슈 항목 (카드 형태 - 현황판용) export interface TodayIssueItem { id: string; label: string; @@ -56,6 +56,26 @@ export interface TodayIssueItem { icon?: React.ComponentType<{ className?: string }>; // 카드 아이콘 } +// 오늘의 이슈 뱃지 타입 +export type TodayIssueListBadgeType = + | '수주 성공' + | '주식 이슈' + | '직정 제고' + | '지출예상내역서' + | '세금 신고' + | '결재 요청' + | '기타'; + +// 오늘의 이슈 리스트 아이템 (리스트 형태 - 새로운 오늘의 이슈용) +export interface TodayIssueListItem { + id: string; + badge: TodayIssueListBadgeType; + content: string; + time: string; // "10분 전", "1시간 전" 등 + needsApproval?: boolean; // 승인/반려 버튼 표시 여부 + path?: string; // 클릭 시 이동할 경로 +} + // 일일 일보 데이터 export interface DailyReportData { date: string; // "2026년 1월 5일 월요일" @@ -135,7 +155,8 @@ export type CalendarTaskFilterType = 'all' | 'schedule' | 'order' | 'constructio // CEO Dashboard 전체 데이터 export interface CEODashboardData { - todayIssue: TodayIssueItem[]; + todayIssue: TodayIssueItem[]; // 현황판용 (구 오늘의 이슈) + todayIssueList: TodayIssueListItem[]; // 새 오늘의 이슈 (리스트 형태) dailyReport: DailyReportData; monthlyExpense: MonthlyExpenseData; cardManagement: CardManagementData; @@ -194,8 +215,10 @@ export interface WelfareSettings { // 대시보드 전체 설정 export interface DashboardSettings { - // 오늘의 이슈 섹션 - todayIssue: { + // 오늘의 이슈 섹션 (새 리스트 형태) + todayIssueList: boolean; + // 현황판 섹션 (구 오늘의 이슈 - 카드 형태) + statusBoard: { enabled: boolean; items: TodayIssueSettings; }; @@ -212,6 +235,11 @@ export interface DashboardSettings { debtCollection: boolean; vat: boolean; calendar: boolean; + // Legacy: 기존 todayIssue 호환용 (deprecated, statusBoard로 대체) + todayIssue: { + enabled: boolean; + items: TodayIssueSettings; + }; } // ===== 상세 모달 공통 타입 ===== @@ -398,7 +426,10 @@ export interface DetailModalConfig { // 기본 설정값 export const DEFAULT_DASHBOARD_SETTINGS: DashboardSettings = { - todayIssue: { + // 새 오늘의 이슈 (리스트 형태) + todayIssueList: true, + // 현황판 (구 오늘의 이슈 - 카드 형태) + statusBoard: { enabled: true, items: { orders: true, @@ -436,4 +467,20 @@ export const DEFAULT_DASHBOARD_SETTINGS: DashboardSettings = { debtCollection: true, vat: true, calendar: true, + // Legacy: 기존 todayIssue 호환용 (statusBoard와 동일) + todayIssue: { + enabled: true, + items: { + orders: true, + debtCollection: true, + safetyStock: true, + taxReport: false, + newVendor: false, + annualLeave: true, + lateness: true, + absence: false, + purchase: false, + approvalRequest: false, + }, + }, }; \ No newline at end of file diff --git a/src/components/business/construction/bidding/BiddingListClient.tsx b/src/components/business/construction/bidding/BiddingListClient.tsx index 9ed91fc0..77abedae 100644 --- a/src/components/business/construction/bidding/BiddingListClient.tsx +++ b/src/components/business/construction/bidding/BiddingListClient.tsx @@ -6,15 +6,8 @@ import { FileText, Clock, Trophy, Pencil } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { TableCell, TableRow } from '@/components/ui/table'; import { Checkbox } from '@/components/ui/checkbox'; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@/components/ui/select'; -import { MultiSelectCombobox, MultiSelectOption } from '@/components/ui/multi-select-combobox'; -import { IntegratedListTemplateV2, TableColumn, StatCard } from '@/components/templates/IntegratedListTemplateV2'; +import { MultiSelectOption } from '@/components/ui/multi-select-combobox'; +import { IntegratedListTemplateV2, TableColumn, StatCard, FilterFieldConfig, FilterValues } from '@/components/templates/IntegratedListTemplateV2'; import { MobileCard } from '@/components/molecules/MobileCard'; import { DateRangeSelector } from '@/components/molecules/DateRangeSelector'; import { toast } from 'sonner'; @@ -335,6 +328,81 @@ export default function BiddingListClient({ initialData = [], initialStats }: Bi } }, [selectedItems, loadData]); + // ===== filterConfig 기반 통합 필터 시스템 ===== + const filterConfig: FilterFieldConfig[] = useMemo(() => [ + { + key: 'partner', + label: '거래처', + type: 'multi', + options: MOCK_PARTNERS.map(o => ({ + value: o.value, + label: o.label, + })), + }, + { + key: 'bidder', + label: '입찰자', + type: 'multi', + options: MOCK_BIDDERS.map(o => ({ + value: o.value, + label: o.label, + })), + }, + { + key: 'status', + label: '상태', + type: 'single', + options: BIDDING_STATUS_OPTIONS.filter(o => o.value !== 'all').map(o => ({ + value: o.value, + label: o.label, + })), + allOptionLabel: '전체', + }, + { + key: 'sortBy', + label: '정렬', + type: 'single', + options: BIDDING_SORT_OPTIONS.map(o => ({ + value: o.value, + label: o.label, + })), + allOptionLabel: '최신순 (입찰일)', + }, + ], []); + + const filterValues: FilterValues = useMemo(() => ({ + partner: partnerFilters, + bidder: bidderFilters, + status: statusFilter, + sortBy: sortBy, + }), [partnerFilters, bidderFilters, statusFilter, sortBy]); + + const handleFilterChange = useCallback((key: string, value: string | string[]) => { + switch (key) { + case 'partner': + setPartnerFilters(value as string[]); + break; + case 'bidder': + setBidderFilters(value as string[]); + break; + case 'status': + setStatusFilter(value as string); + break; + case 'sortBy': + setSortBy(value as string); + break; + } + setCurrentPage(1); + }, []); + + const handleFilterReset = useCallback(() => { + setPartnerFilters([]); + setBidderFilters([]); + setStatusFilter('all'); + setSortBy('biddingDateDesc'); + setCurrentPage(1); + }, []); + // 테이블 행 렌더링 const renderTableRow = useCallback( (bidding: Bidding, index: number, globalIndex: number) => { @@ -450,63 +518,6 @@ export default function BiddingListClient({ initialData = [], initialStats }: Bi }, ]; - // 테이블 헤더 액션 (총 건수 + 필터 4개: 거래처, 입찰자, 상태, 정렬) - const tableHeaderActions = ( -
- - 총 {sortedBiddings.length}건 - - - {/* 거래처 필터 (다중선택) */} - - - {/* 입찰자 필터 (다중선택) */} - - - {/* 상태 필터 */} - - - {/* 정렬 */} - -
- ); - return ( <> ( @@ -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/handover-report/HandoverReportListClient.tsx b/src/components/business/construction/handover-report/HandoverReportListClient.tsx index 194314c4..c66d359d 100644 --- a/src/components/business/construction/handover-report/HandoverReportListClient.tsx +++ b/src/components/business/construction/handover-report/HandoverReportListClient.tsx @@ -6,15 +6,8 @@ import { FileText, Clock, CheckCircle, Pencil } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { TableCell, TableRow } from '@/components/ui/table'; import { Checkbox } from '@/components/ui/checkbox'; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@/components/ui/select'; -import { MultiSelectCombobox, MultiSelectOption } from '@/components/ui/multi-select-combobox'; -import { IntegratedListTemplateV2, TableColumn, StatCard } from '@/components/templates/IntegratedListTemplateV2'; +import { MultiSelectOption } from '@/components/ui/multi-select-combobox'; +import { IntegratedListTemplateV2, TableColumn, StatCard, FilterFieldConfig, FilterValues } from '@/components/templates/IntegratedListTemplateV2'; import { MobileCard } from '@/components/molecules/MobileCard'; import { DateRangeSelector } from '@/components/molecules/DateRangeSelector'; import { toast } from 'sonner'; @@ -272,6 +265,95 @@ export default function HandoverReportListClient({ [router] ); + // ===== filterConfig 기반 통합 필터 시스템 ===== + const filterConfig: FilterFieldConfig[] = useMemo(() => [ + { + key: 'partner', + label: '거래처', + type: 'multi', + options: MOCK_PARTNERS.map(o => ({ + value: o.value, + label: o.label, + })), + }, + { + key: 'contractManager', + label: '계약담당자', + type: 'multi', + options: MOCK_CONTRACT_MANAGERS.map(o => ({ + value: o.value, + label: o.label, + })), + }, + { + key: 'constructionPM', + label: '공사PM', + type: 'multi', + options: MOCK_CONSTRUCTION_PMS.map(o => ({ + value: o.value, + label: o.label, + })), + }, + { + key: 'status', + label: '상태', + type: 'single', + options: REPORT_STATUS_OPTIONS.filter(o => o.value !== 'all').map(o => ({ + value: o.value, + label: o.label, + })), + allOptionLabel: '전체', + }, + { + key: 'sortBy', + label: '정렬', + type: 'single', + options: REPORT_SORT_OPTIONS.map(o => ({ + value: o.value, + label: o.label, + })), + allOptionLabel: '최신순 (계약시작일)', + }, + ], []); + + const filterValues: FilterValues = useMemo(() => ({ + partner: partnerFilters, + contractManager: contractManagerFilters, + constructionPM: constructionPMFilters, + status: statusFilter, + sortBy: sortBy, + }), [partnerFilters, contractManagerFilters, constructionPMFilters, statusFilter, sortBy]); + + const handleFilterChange = useCallback((key: string, value: string | string[]) => { + switch (key) { + case 'partner': + setPartnerFilters(value as string[]); + break; + case 'contractManager': + setContractManagerFilters(value as string[]); + break; + case 'constructionPM': + setConstructionPMFilters(value as string[]); + break; + case 'status': + setStatusFilter(value as string); + break; + case 'sortBy': + setSortBy(value as string); + break; + } + setCurrentPage(1); + }, []); + + const handleFilterReset = useCallback(() => { + setPartnerFilters([]); + setContractManagerFilters([]); + setConstructionPMFilters([]); + setStatusFilter('all'); + setSortBy('contractDateDesc'); + setCurrentPage(1); + }, []); + // 테이블 행 렌더링 const renderTableRow = useCallback( (report: HandoverReport, index: number, globalIndex: number) => { @@ -389,73 +471,6 @@ export default function HandoverReportListClient({ }, ]; - // 테이블 헤더 액션 - const tableHeaderActions = ( -
- - 총 {sortedReports.length}건 - - - {/* 거래처 필터 */} - - - {/* 계약담당자 필터 */} - - - {/* 공사PM 필터 */} - - - {/* 상태 필터 */} - - - {/* 정렬 */} - -
- ); - return ( <> (null); + + // 철회 다이얼로그 + const [withdrawDialogOpen, setWithdrawDialogOpen] = useState(false); + + // 폼 상태 + const [formData, setFormData] = useState({ + issueNumber: issue?.issueNumber || '', + constructionNumber: issue?.constructionNumber || '', + partnerName: issue?.partnerName || '', + siteName: issue?.siteName || '', + constructionPM: issue?.constructionPM || '', + constructionManagers: issue?.constructionManagers || '', + reporter: issue?.reporter || '', + assignee: issue?.assignee || '', + reportDate: issue?.reportDate || new Date().toISOString().split('T')[0], + resolvedDate: issue?.resolvedDate || '', + status: issue?.status || 'received', + category: issue?.category || 'material', + priority: issue?.priority || 'normal', + title: issue?.title || '', + content: issue?.content || '', + images: issue?.images || [], + }); + + const [isSubmitting, setIsSubmitting] = useState(false); + + // 시공번호 변경 시 관련 정보 자동 채움 + useEffect(() => { + if (formData.constructionNumber) { + const construction = MOCK_CONSTRUCTION_NUMBERS.find( + (c) => c.value === formData.constructionNumber + ); + if (construction) { + setFormData((prev) => ({ + ...prev, + partnerName: construction.partnerName, + siteName: construction.siteName, + constructionPM: construction.pm, + constructionManagers: construction.managers, + })); + } + } + }, [formData.constructionNumber]); + + // 담당자 지정 시 상태를 처리중으로 자동 변경 + const handleAssigneeChange = useCallback((value: string) => { + setFormData((prev) => ({ + ...prev, + assignee: value, + // 담당자가 지정되고 현재 상태가 '접수'이면 '처리중'으로 변경 + status: value && prev.status === 'received' ? 'in_progress' : prev.status, + })); + if (value && formData.status === 'received') { + toast.info('담당자가 지정되어 상태가 "처리중"으로 변경되었습니다.'); + } + }, [formData.status]); + + // 중요도 변경 시 긴급이면 알림 표시 + const handlePriorityChange = useCallback((value: string) => { + setFormData((prev) => ({ ...prev, priority: value as IssuePriority })); + if (value === 'urgent') { + toast.warning('긴급 이슈로 설정되었습니다. 공사PM과 대표에게 알림이 발송됩니다.'); + } + }, []); + + // 입력 핸들러 + const handleInputChange = useCallback( + (field: keyof IssueFormData) => (e: React.ChangeEvent) => { + setFormData((prev) => ({ ...prev, [field]: e.target.value })); + }, + [] + ); + + const handleSelectChange = useCallback((field: keyof IssueFormData) => (value: string) => { + setFormData((prev) => ({ ...prev, [field]: value })); + }, []); + + // 수정 버튼 클릭 + const handleEditClick = useCallback(() => { + if (issue?.id) { + router.push(`/ko/construction/project/issue-management/${issue.id}/edit`); + } + }, [router, issue?.id]); + + // 저장 + const handleSubmit = useCallback(async () => { + if (!formData.title.trim()) { + toast.error('제목을 입력해주세요.'); + return; + } + if (!formData.constructionNumber) { + toast.error('시공번호를 선택해주세요.'); + return; + } + + setIsSubmitting(true); + try { + if (isCreateMode) { + const result = await createIssue({ + issueNumber: `ISS-${Date.now()}`, + constructionNumber: formData.constructionNumber, + partnerName: formData.partnerName, + siteName: formData.siteName, + constructionPM: formData.constructionPM, + constructionManagers: formData.constructionManagers, + category: formData.category, + title: formData.title, + content: formData.content, + reporter: formData.reporter, + reportDate: formData.reportDate, + resolvedDate: formData.resolvedDate || null, + assignee: formData.assignee, + priority: formData.priority, + status: formData.status, + images: formData.images, + }); + if (result.success) { + toast.success('이슈가 등록되었습니다.'); + router.push('/ko/construction/project/issue-management'); + } else { + toast.error(result.error || '이슈 등록에 실패했습니다.'); + } + } else { + const result = await updateIssue(issue!.id, { + constructionNumber: formData.constructionNumber, + partnerName: formData.partnerName, + siteName: formData.siteName, + constructionPM: formData.constructionPM, + constructionManagers: formData.constructionManagers, + category: formData.category, + title: formData.title, + content: formData.content, + reporter: formData.reporter, + reportDate: formData.reportDate, + resolvedDate: formData.resolvedDate || null, + assignee: formData.assignee, + priority: formData.priority, + status: formData.status, + images: formData.images, + }); + if (result.success) { + toast.success('이슈가 수정되었습니다.'); + router.push('/ko/construction/project/issue-management'); + } else { + toast.error(result.error || '이슈 수정에 실패했습니다.'); + } + } + } catch { + toast.error('저장에 실패했습니다.'); + } finally { + setIsSubmitting(false); + } + }, [formData, isCreateMode, issue, router]); + + // 취소 + const handleCancel = useCallback(() => { + router.back(); + }, [router]); + + // 철회 + const handleWithdraw = useCallback(async () => { + if (!issue?.id) return; + try { + const result = await withdrawIssue(issue.id); + if (result.success) { + toast.success('이슈가 철회되었습니다.'); + router.push('/ko/construction/project/issue-management'); + } else { + toast.error(result.error || '이슈 철회에 실패했습니다.'); + } + } catch { + toast.error('이슈 철회에 실패했습니다.'); + } finally { + setWithdrawDialogOpen(false); + } + }, [issue?.id, router]); + + // 이미지 업로드 핸들러 + const handleImageUpload = useCallback((e: React.ChangeEvent) => { + const files = e.target.files; + if (!files || files.length === 0) return; + + const newImages: IssueImage[] = Array.from(files).map((file, index) => ({ + id: `img-${Date.now()}-${index}`, + url: URL.createObjectURL(file), + fileName: file.name, + uploadedAt: new Date().toISOString(), + })); + + setFormData((prev) => ({ + ...prev, + images: [...prev.images, ...newImages], + })); + toast.success(`${files.length}개의 이미지가 추가되었습니다.`); + + // 입력 초기화 + if (imageInputRef.current) { + imageInputRef.current.value = ''; + } + }, []); + + // 이미지 삭제 + const handleImageRemove = useCallback((imageId: string) => { + setFormData((prev) => ({ + ...prev, + images: prev.images.filter((img) => img.id !== imageId), + })); + toast.success('이미지가 삭제되었습니다.'); + }, []); + + // 녹음 버튼 (UI만) + const handleRecordClick = useCallback(() => { + toast.info('녹음 기능은 준비 중입니다.'); + }, []); + + // 읽기 전용 여부 + const isReadOnly = isViewMode; + + return ( + + + + + +
+ ) : ( +
+ + + +
+ ) + } + /> + +
+ {/* 이슈 정보 카드 */} + + + 이슈 정보 + + +
+ {/* 이슈번호 */} +
+ + +
+ + {/* 시공번호 */} +
+ + +
+ + {/* 거래처 */} +
+ + +
+ + {/* 현장 */} +
+ + +
+ + {/* 공사PM (자동) */} +
+ + +
+ + {/* 공사담당자 (자동) */} +
+ + +
+ + {/* 보고자 */} +
+ + +
+ + {/* 담당자 */} +
+ + +
+ + {/* 이슈보고일 */} +
+ + +
+ + {/* 이슈해결일 */} +
+ + +
+ + {/* 상태 */} +
+ + +
+
+
+
+ + {/* 이슈 보고 카드 */} + + + 이슈 보고 + + +
+ {/* 구분 & 중요도 */} +
+ {/* 구분 */} +
+ + +
+ + {/* 중요도 */} +
+ + +
+
+ + {/* 제목 */} +
+ + +
+ + {/* 내용 */} +
+
+ + {!isReadOnly && ( + + )} +
+