Merge remote-tracking branch 'origin/master'
# Conflicts: # src/app/[locale]/(protected)/construction/project/bidding/estimates/[id]/edit/page.tsx # src/app/[locale]/(protected)/construction/project/bidding/estimates/[id]/page.tsx
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
# 권한 관리 시스템 현황 분석
|
||||
|
||||
> 작성일: 2026-01-07
|
||||
> 최종 수정일: 2026-01-12
|
||||
> 목적: SAM 프로젝트 권한 시스템 현황 파악 및 향후 구현 계획 정리
|
||||
|
||||
---
|
||||
@@ -10,13 +11,13 @@
|
||||
| 구분 | 상태 | 설명 |
|
||||
|------|------|------|
|
||||
| 권한 설정 UI | ✅ 완성 | `/settings/permissions/[id]`에서 역할별 권한 설정 가능 |
|
||||
| 백엔드 권한 API | ✅ 존재 | 권한 매트릭스 조회/설정 API 구현됨 |
|
||||
| 백엔드 권한 API | ✅ 완성 | 권한 매트릭스 조회/설정 API 구현됨 |
|
||||
| 백엔드 API 권한 체크 | ⚠️ 구조만 있음 | 미들웨어 존재하나 라우트에 미적용 |
|
||||
| 프론트 권한 체크 | ❌ 미구현 | 권한 매트릭스 조회 및 UI 제어 로직 없음 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 권한 타입 (7가지)
|
||||
## 2. 권한 타입 (5가지)
|
||||
|
||||
| 권한 | 영문 | 적용 대상 |
|
||||
|------|------|----------|
|
||||
@@ -25,8 +26,8 @@
|
||||
| 수정 | `update` | 수정 버튼 |
|
||||
| 삭제 | `delete` | 삭제 버튼 |
|
||||
| 승인 | `approve` | 승인/반려 버튼 |
|
||||
| 내보내기 | `export` | Excel 다운로드 등 |
|
||||
| 관리 | `manage` | 관리자 전용 기능 |
|
||||
|
||||
> ⚠️ **참고**: `export`, `manage` 권한은 백엔드에 미구현 상태
|
||||
|
||||
---
|
||||
|
||||
@@ -51,32 +52,91 @@
|
||||
|
||||
### 3.2 권한 매트릭스 조회 API
|
||||
|
||||
**사용자별 권한 조회**:
|
||||
**사용자별 권한 조회** (프론트엔드에서 사용):
|
||||
```
|
||||
GET /api/v1/permissions/users/{userId}/menu-matrix
|
||||
```
|
||||
|
||||
**응답 구조**:
|
||||
**실제 응답 구조**:
|
||||
```json
|
||||
{
|
||||
"permission_types": ["view", "create", "update", "delete", "approve", "export", "manage"],
|
||||
"permissions": {
|
||||
"1": { "view": true, "create": true, "update": false, ... },
|
||||
"2": { "view": true, "create": false, ... }
|
||||
"success": true,
|
||||
"message": "유저 메뉴 권한 매트릭스 조회 성공",
|
||||
"data": {
|
||||
"actions": ["view", "create", "update", "delete", "approve"],
|
||||
"tree": [
|
||||
{
|
||||
"menu_id": 1,
|
||||
"parent_id": null,
|
||||
"name": "대시보드",
|
||||
"url": "/dashboard",
|
||||
"type": "system",
|
||||
"children": [
|
||||
{
|
||||
"menu_id": 2,
|
||||
"parent_id": 1,
|
||||
"name": "CEO 대시보드",
|
||||
"url": "/dashboard/ceo",
|
||||
"children": [],
|
||||
"actions": { ... }
|
||||
}
|
||||
],
|
||||
"actions": {
|
||||
"view": {
|
||||
"permission_id": 123,
|
||||
"permission_code": "menu:1.view",
|
||||
"guard_name": "api",
|
||||
"state": "allow",
|
||||
"is_allowed": 1
|
||||
},
|
||||
"create": {
|
||||
"permission_id": 124,
|
||||
"permission_code": "menu:1.create",
|
||||
"guard_name": "api",
|
||||
"state": "deny",
|
||||
"is_allowed": 0
|
||||
},
|
||||
"update": null,
|
||||
"delete": null,
|
||||
"approve": null
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3.3 기타 권한 API
|
||||
**권한 상태 값**:
|
||||
| state | is_allowed | 의미 |
|
||||
|-------|------------|------|
|
||||
| `allow` | 1 | 권한 허용됨 |
|
||||
| `deny` | 0 | 권한 명시적 거부 |
|
||||
| `none` | 0 | 권한 미설정 (기본 거부) |
|
||||
|
||||
| 엔드포인트 | 설명 |
|
||||
|-----------|------|
|
||||
| `GET /api/v1/permissions/departments/{dept_id}/menu-matrix` | 부서별 권한 매트릭스 |
|
||||
| `GET /api/v1/permissions/roles/{role_id}/menu-matrix` | 역할별 권한 매트릭스 |
|
||||
| `GET /api/v1/roles/{id}/permissions/matrix` | 역할 권한 매트릭스 (설정 UI용) |
|
||||
| `POST /api/v1/roles/{id}/permissions/toggle` | 개별 권한 토글 |
|
||||
| `POST /api/v1/roles/{id}/permissions/allow-all` | 전체 허용 |
|
||||
| `POST /api/v1/roles/{id}/permissions/deny-all` | 전체 거부 |
|
||||
**actions가 null인 경우**: 해당 메뉴에 해당 권한이 정의되지 않음
|
||||
|
||||
### 3.3 권한 매트릭스 API 목록
|
||||
|
||||
| 엔드포인트 | 메서드 | 설명 |
|
||||
|-----------|--------|------|
|
||||
| `/api/v1/permissions/users/{user_id}/menu-matrix` | GET | 사용자별 권한 매트릭스 |
|
||||
| `/api/v1/permissions/roles/{role_id}/menu-matrix` | GET | 역할별 권한 매트릭스 |
|
||||
| `/api/v1/permissions/departments/{dept_id}/menu-matrix` | GET | 부서별 권한 매트릭스 |
|
||||
|
||||
### 3.4 역할 권한 관리 API
|
||||
|
||||
| 엔드포인트 | 메서드 | 설명 |
|
||||
|-----------|--------|------|
|
||||
| `/api/v1/role-permissions/menus` | GET | 권한 설정용 메뉴 트리 |
|
||||
| `/api/v1/roles/{id}/permissions` | GET | 역할 권한 목록 |
|
||||
| `/api/v1/roles/{id}/permissions` | POST | 역할 권한 부여 |
|
||||
| `/api/v1/roles/{id}/permissions` | DELETE | 역할 권한 회수 |
|
||||
| `/api/v1/roles/{id}/permissions/sync` | PUT | 역할 권한 동기화 |
|
||||
| `/api/v1/roles/{id}/permissions/matrix` | GET | 역할 권한 매트릭스 (설정 UI용) |
|
||||
| `/api/v1/roles/{id}/permissions/toggle` | POST | 개별 권한 토글 |
|
||||
| `/api/v1/roles/{id}/permissions/allow-all` | POST | 전체 허용 |
|
||||
| `/api/v1/roles/{id}/permissions/deny-all` | POST | 전체 거부 |
|
||||
| `/api/v1/roles/{id}/permissions/reset` | POST | 기본값 초기화 (view만 허용) |
|
||||
|
||||
---
|
||||
|
||||
@@ -124,13 +184,15 @@ HTTP 메서드에 따라 액션 자동 매핑:
|
||||
- 로그인 시 `menus`, `roles` 데이터 저장 (localStorage)
|
||||
- 사이드바 메뉴 표시 (백엔드에서 필터링된 메뉴)
|
||||
- 메뉴 폴링 (30초 주기)
|
||||
- 역할별 권한 설정 UI (`/settings/permissions/[id]`)
|
||||
|
||||
### 5.2 미구현 사항
|
||||
|
||||
- 권한 매트릭스 API 호출
|
||||
- 권한 데이터 저장
|
||||
- 권한 데이터 저장 (permissionStore)
|
||||
- `usePermission` 훅
|
||||
- 페이지/버튼별 권한 체크
|
||||
- 환경 변수 플래그
|
||||
|
||||
---
|
||||
|
||||
@@ -143,7 +205,7 @@ HTTP 메서드에 따라 액션 자동 매핑:
|
||||
↓
|
||||
/api/v1/permissions/users/{userId}/menu-matrix 호출
|
||||
↓
|
||||
권한 매트릭스 저장 (Zustand/localStorage)
|
||||
권한 매트릭스 저장 (Zustand permissionStore)
|
||||
↓
|
||||
usePermission 훅으로 권한 체크
|
||||
↓
|
||||
@@ -152,12 +214,13 @@ usePermission 훅으로 권한 체크
|
||||
|
||||
**usePermission 훅 예시**:
|
||||
```typescript
|
||||
// 사용법
|
||||
const { canView, canCreate, canUpdate, canDelete } = usePermission('판매관리');
|
||||
// 사용법 (메뉴명 또는 URL로 조회)
|
||||
const { canView, canCreate, canUpdate, canDelete, canApprove } = usePermission('/sales/orders');
|
||||
|
||||
// 적용
|
||||
{canCreate && <Button>등록</Button>}
|
||||
{canDelete && <Button>삭제</Button>}
|
||||
{canApprove && <Button>승인</Button>}
|
||||
```
|
||||
|
||||
**환경 변수 플래그**:
|
||||
@@ -209,6 +272,7 @@ Route::post('/orders', [OrderController::class, 'store'])
|
||||
| `src/components/settings/PermissionManagement/` | 권한 관리 컴포넌트 |
|
||||
| `src/layouts/AuthenticatedLayout.tsx` | 메뉴 표시 레이아웃 |
|
||||
| `src/middleware.ts` | 인증 체크 (권한 체크 없음) |
|
||||
| `src/store/menuStore.ts` | 메뉴 상태 관리 |
|
||||
|
||||
### 백엔드 (sam-api)
|
||||
|
||||
@@ -218,7 +282,9 @@ Route::post('/orders', [OrderController::class, 'store'])
|
||||
| `app/Http/Controllers/Api/V1/RolePermissionController.php` | 역할 권한 API |
|
||||
| `app/Http/Middleware/CheckPermission.php` | 권한 체크 미들웨어 |
|
||||
| `app/Http/Middleware/PermMapper.php` | HTTP → 액션 매핑 |
|
||||
| `app/Services/PermissionService.php` | 권한 매트릭스 서비스 |
|
||||
| `app/Services/Authz/AccessService.php` | 권한 판정 서비스 |
|
||||
| `app/Services/Authz/RolePermissionService.php` | 역할 권한 서비스 |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -93,4 +93,37 @@
|
||||
|
||||
---
|
||||
|
||||
*2025-11-27 작성*
|
||||
## 공통 UI 컴포넌트 사용 규칙
|
||||
|
||||
### 로딩 스피너
|
||||
|
||||
**필수**: 로딩 상태 표시 시 반드시 공통 스피너 컴포넌트 사용
|
||||
|
||||
```tsx
|
||||
import {
|
||||
ContentLoadingSpinner,
|
||||
PageLoadingSpinner,
|
||||
TableLoadingSpinner,
|
||||
ButtonSpinner
|
||||
} from '@/components/ui/loading-spinner';
|
||||
```
|
||||
|
||||
| 컴포넌트 | 용도 | 예시 |
|
||||
|----------|------|------|
|
||||
| `ContentLoadingSpinner` | 상세/수정 페이지 컨텐츠 영역 | `if (isLoading) return <ContentLoadingSpinner />;` |
|
||||
| `PageLoadingSpinner` | 페이지 전환, 전체 페이지 | loading.tsx, 초기 로딩 |
|
||||
| `TableLoadingSpinner` | 테이블/리스트 영역 | 데이터 테이블 로딩 |
|
||||
| `ButtonSpinner` | 버튼 내부 (저장 중 등) | `{isSaving && <ButtonSpinner />}` |
|
||||
|
||||
**금지 패턴:**
|
||||
```tsx
|
||||
// ❌ 텍스트만 사용 금지
|
||||
<div className="text-muted-foreground">로딩 중...</div>
|
||||
|
||||
// ❌ 직접 스피너 구현 금지
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500" />
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
*2025-11-27 작성 / 2026-01-12 스피너 규칙 추가*
|
||||
|
||||
153
claudedocs/[IMPL-2026-01-12] permission-frontend-checklist.md
Normal file
153
claudedocs/[IMPL-2026-01-12] permission-frontend-checklist.md
Normal file
@@ -0,0 +1,153 @@
|
||||
# 프론트엔드 권한 시스템 구현 체크리스트
|
||||
|
||||
> 작성일: 2026-01-12
|
||||
> 참고 문서: [ANALYSIS-2026-01-07] permission-system-status.md
|
||||
|
||||
---
|
||||
|
||||
## 구현 목표
|
||||
|
||||
로그인한 사용자의 권한에 따라 UI 요소(버튼, 메뉴 등)를 동적으로 표시/숨김 처리
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: 기반 구조 구축
|
||||
|
||||
### 1.1 타입 정의
|
||||
- [ ] `src/types/permission.ts` 생성
|
||||
- [ ] `PermissionAction` 타입 (view, create, update, delete, approve)
|
||||
- [ ] `PermissionState` 타입 (allow, deny, none)
|
||||
- [ ] `MenuPermission` 인터페이스 (API 응답 구조)
|
||||
- [ ] `PermissionMatrix` 인터페이스 (트리 → 플랫 변환용)
|
||||
|
||||
### 1.2 환경 변수 설정
|
||||
- [ ] `.env.local`에 `NEXT_PUBLIC_ENABLE_AUTHORIZATION=false` 추가
|
||||
- [ ] `.env.example`에 동일 항목 추가 (문서화)
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: 상태 관리
|
||||
|
||||
### 2.1 Permission Store 생성
|
||||
- [ ] `src/store/permissionStore.ts` 생성
|
||||
- [ ] 상태 정의
|
||||
- [ ] `permissions`: URL 기반 권한 맵 (`Record<string, PermissionActions>`)
|
||||
- [ ] `isLoaded`: 권한 로딩 완료 여부
|
||||
- [ ] `isEnabled`: 환경 변수 기반 활성화 여부
|
||||
- [ ] 액션 정의
|
||||
- [ ] `setPermissions(tree)`: API 응답 트리를 플랫 맵으로 변환 저장
|
||||
- [ ] `clearPermissions()`: 로그아웃 시 초기화
|
||||
- [ ] `hasPermission(url, action)`: 권한 체크 함수
|
||||
- [ ] persist 미들웨어 적용 (localStorage)
|
||||
|
||||
### 2.2 유틸리티 함수
|
||||
- [ ] `src/lib/permission-utils.ts` 생성
|
||||
- [ ] `flattenPermissionTree(tree)`: 트리 구조를 URL 기반 플랫 맵으로 변환
|
||||
- [ ] `normalizeUrl(url)`: URL 정규화 (locale 제거 등)
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: API 연동
|
||||
|
||||
### 3.1 Server Action 생성
|
||||
- [ ] `src/lib/api/permissions/actions.ts` 생성
|
||||
- [ ] `getUserPermissions(userId)`: 권한 매트릭스 API 호출
|
||||
|
||||
### 3.2 로그인 플로우 연동
|
||||
- [ ] 로그인 성공 후 권한 API 호출 로직 추가
|
||||
- [ ] `AuthenticatedLayout.tsx` 또는 로그인 처리 부분에서 호출
|
||||
- [ ] 권한 로딩 중 상태 처리 (로딩 UI 또는 스켈레톤)
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: usePermission 훅 구현
|
||||
|
||||
### 4.1 훅 생성
|
||||
- [ ] `src/hooks/usePermission.ts` 생성
|
||||
- [ ] 입력: 메뉴 URL 또는 메뉴명
|
||||
- [ ] 출력:
|
||||
```typescript
|
||||
{
|
||||
canView: boolean;
|
||||
canCreate: boolean;
|
||||
canUpdate: boolean;
|
||||
canDelete: boolean;
|
||||
canApprove: boolean;
|
||||
isLoading: boolean;
|
||||
}
|
||||
```
|
||||
- [ ] 환경 변수 비활성화 시 모두 `true` 반환
|
||||
|
||||
### 4.2 편의 컴포넌트 (선택사항)
|
||||
- [ ] `src/components/common/PermissionGuard.tsx` 생성
|
||||
```typescript
|
||||
<PermissionGuard menu="/sales/orders" action="create">
|
||||
<Button>등록</Button>
|
||||
</PermissionGuard>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: 적용 및 테스트
|
||||
|
||||
### 5.1 샘플 페이지 적용
|
||||
- [ ] 테스트용 페이지 1개 선정 (예: 판매관리)
|
||||
- [ ] 등록/수정/삭제 버튼에 권한 체크 적용
|
||||
- [ ] 동작 확인
|
||||
|
||||
### 5.2 전체 적용 (점진적)
|
||||
- [ ] 주요 페이지 목록 작성
|
||||
- [ ] 각 페이지별 권한 적용 진행
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: 예외 처리 및 UX
|
||||
|
||||
### 6.1 에러 처리
|
||||
- [ ] 권한 API 실패 시 fallback 처리 (모두 허용 or 모두 거부)
|
||||
- [ ] 네트워크 오류 시 재시도 로직
|
||||
|
||||
### 6.2 UX 개선
|
||||
- [ ] 권한 없는 버튼: 숨김 vs 비활성화(disabled) 정책 결정
|
||||
- [ ] 권한 없는 페이지 접근 시 처리 (리다이렉트 or 안내 메시지)
|
||||
|
||||
---
|
||||
|
||||
## 파일 생성 목록 요약
|
||||
|
||||
| 파일 경로 | 설명 |
|
||||
|----------|------|
|
||||
| `src/types/permission.ts` | 권한 관련 타입 정의 |
|
||||
| `src/store/permissionStore.ts` | 권한 상태 관리 (Zustand) |
|
||||
| `src/lib/permission-utils.ts` | 권한 유틸리티 함수 |
|
||||
| `src/lib/api/permissions/actions.ts` | 권한 API Server Action |
|
||||
| `src/hooks/usePermission.ts` | 권한 체크 훅 |
|
||||
| `src/components/common/PermissionGuard.tsx` | 권한 가드 컴포넌트 (선택) |
|
||||
|
||||
---
|
||||
|
||||
## 의존성
|
||||
|
||||
- 추가 패키지 설치 불필요 (기존 Zustand 활용)
|
||||
|
||||
---
|
||||
|
||||
## 주의사항
|
||||
|
||||
1. **환경 변수 기본값**: 개발 중에는 `NEXT_PUBLIC_ENABLE_AUTHORIZATION=false`로 비활성화
|
||||
2. **플랫 맵 변환**: API 응답이 트리 구조이므로 URL 기반 플랫 맵으로 변환 필요
|
||||
3. **URL 정규화**: locale prefix (`/ko`, `/en`) 제거하여 비교
|
||||
4. **로그아웃 시 초기화**: permissionStore 클리어 필수
|
||||
|
||||
---
|
||||
|
||||
## 예상 작업 순서
|
||||
|
||||
```
|
||||
Phase 1 (타입/환경변수) → Phase 2 (스토어) → Phase 3 (API 연동)
|
||||
→ Phase 4 (훅) → Phase 5 (적용) → Phase 6 (예외처리)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
*체크리스트 완료 후 이 문서를 archive로 이동*
|
||||
52
claudedocs/[IMPL-2026-01-12] project-detail-checklist.md
Normal file
52
claudedocs/[IMPL-2026-01-12] project-detail-checklist.md
Normal file
@@ -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
|
||||
133
claudedocs/[IMPL-2026-01-12] quote-v2-test-pages-checklist.md
Normal file
133
claudedocs/[IMPL-2026-01-12] quote-v2-test-pages-checklist.md
Normal file
@@ -0,0 +1,133 @@
|
||||
# [IMPL-2026-01-12] 견적 V2 테스트 페이지 구현
|
||||
|
||||
## 개요
|
||||
- **목적**: 견적 등록/상세/수정 페이지의 새로운 UI (자동 견적 산출 V2) 테스트
|
||||
- **원칙**: 기존 견적관리 페이지는 절대 수정하지 않음 (API 연결됨)
|
||||
- **범위**: 테스트 페이지 3개 + 새 컴포넌트 생성
|
||||
|
||||
---
|
||||
|
||||
## 스크린샷 기반 UI 구성
|
||||
|
||||
### 레이아웃 구조
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ [발주 개소 목록 (3)] │ [1층 / FSS-01 상세정보] │
|
||||
│ ┌──────────────────────┐ │ 제품명: KSS01 │
|
||||
│ │ 층 │ 부호 │사이즈│제품│수량│ 오픈사이즈: 5000 × 3000 │
|
||||
│ │ 1층│FSS-01│5000×3000│KSS01│1│ 제작사이즈/중량/면적/수량 │
|
||||
│ │ 3층│FST-30│7500×3300│KSS02│1│ ───────────────────── │
|
||||
│ │ 5층│FSS-50│6000×2800│KSS01│2│ 필수설정: 가이드레일/전원/제어기│
|
||||
│ └──────────────────────┘ │ ───────────────────── │
|
||||
│ [품목 추가 폼] │ [탭: 본체│철골품-가이드레일│...]│
|
||||
│ 층|부호|가로|세로|제품명|수량 │ [품목 테이블] │
|
||||
│ 가이드레일|전원|제어기 [+][↑] │ │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ 💰 견적 금액 요약 │
|
||||
│ ┌─────────────────┐ ┌──────────────────────────────┐ │
|
||||
│ │ 개소별 합계 │ │ 상세별 합계 (선택 개소) │ │
|
||||
│ │ 1층/FSS-01 1,645,200│ │ 본체(스크린/슬랫) 1,061,676 │ │
|
||||
│ │ 3층/FST-30 2,589,198│ │ 철골품-가이드레일 116,556 │ │
|
||||
│ │ 5층/FSS-50 3,442,428│ │ ... │ │
|
||||
│ └─────────────────┘ └──────────────────────────────┘ │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ 총 개소 수: 3 │ 예상 견적금액: 11,119,254 │ 견적상태: 작성중│
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ 예상 전체 견적금액 [견적서산출] [임시저장] [최종저장] │
|
||||
│ 11,119,254원 │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 기능 요약
|
||||
| 영역 | 기능 |
|
||||
|------|------|
|
||||
| 발주 개소 목록 | 테이블로 개소 표시, 클릭 시 우측 상세 변경 |
|
||||
| 품목 추가 폼 | 층/부호/사이즈/제품/수량 + 설정 입력 후 [+] 추가 |
|
||||
| 엑셀 업로드 | [↑] 버튼으로 엑셀 일괄 업로드 |
|
||||
| 상세 정보 | 선택 개소의 제품정보, 필수설정, 품목탭 |
|
||||
| 견적 금액 요약 | 개소별 합계 + 상세별 합계 |
|
||||
| 푸터 | 총 개소 수, 예상 견적금액, 견적 상태 |
|
||||
| 버튼 | 견적서 산출, 임시저장, 최종저장 (미리보기 제외) |
|
||||
|
||||
---
|
||||
|
||||
## 파일 구조
|
||||
|
||||
### 테스트 페이지 (새로 생성)
|
||||
```
|
||||
src/app/[locale]/(protected)/sales/quote-management/
|
||||
├── test-new/page.tsx ← 테스트 등록 페이지
|
||||
├── test/[id]/page.tsx ← 테스트 상세 페이지
|
||||
└── test/[id]/edit/page.tsx ← 테스트 수정 페이지
|
||||
```
|
||||
|
||||
### 컴포넌트 (새로 생성)
|
||||
```
|
||||
src/components/quotes/
|
||||
├── QuoteRegistrationV2.tsx ← 메인 컴포넌트 (새 UI)
|
||||
├── LocationListPanel.tsx ← 왼쪽: 발주 개소 목록 + 추가 폼
|
||||
├── LocationDetailPanel.tsx ← 오른쪽: 선택 개소 상세
|
||||
├── QuoteSummaryPanel.tsx ← 견적 금액 요약
|
||||
├── QuoteFooterBar.tsx ← 하단 푸터 바
|
||||
└── ExcelUploadButton.tsx ← 엑셀 업로드/다운로드
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 작업 체크리스트
|
||||
|
||||
### Phase 1: 기본 구조 설정
|
||||
- [ ] 테스트 등록 페이지 생성 (test-new/page.tsx)
|
||||
- [ ] 테스트 상세 페이지 생성 (test/[id]/page.tsx)
|
||||
- [ ] 테스트 수정 페이지 생성 (test/[id]/edit/page.tsx)
|
||||
- [ ] /dev/test-urls에 테스트 URL 추가
|
||||
|
||||
### Phase 2: 핵심 컴포넌트 구현
|
||||
- [ ] QuoteRegistrationV2.tsx 메인 컴포넌트 생성
|
||||
- [ ] LocationListPanel.tsx 발주 개소 목록 구현
|
||||
- [ ] LocationDetailPanel.tsx 상세 정보 구현
|
||||
- [ ] QuoteSummaryPanel.tsx 금액 요약 구현
|
||||
- [ ] QuoteFooterBar.tsx 푸터 바 구현
|
||||
|
||||
### Phase 3: 상세 기능 구현
|
||||
- [ ] 개소 선택 시 우측 상세 변경 기능
|
||||
- [ ] 품목 추가 폼 기능
|
||||
- [ ] 탭 전환 기능 (본체, 철골품 등)
|
||||
- [ ] 품목 테이블 표시
|
||||
|
||||
### Phase 4: 엑셀 기능
|
||||
- [ ] ExcelUploadButton.tsx 컴포넌트 생성
|
||||
- [ ] 엑셀 양식 다운로드 기능
|
||||
- [ ] 엑셀 업로드 및 파싱 기능
|
||||
|
||||
### Phase 5: 버튼 및 저장 기능
|
||||
- [ ] 견적서 산출 버튼 기능
|
||||
- [ ] 임시저장 버튼 기능
|
||||
- [ ] 최종저장 버튼 기능
|
||||
|
||||
---
|
||||
|
||||
## 참고 사항
|
||||
|
||||
### 기존 파일 (수정 금지)
|
||||
- `src/app/[locale]/(protected)/sales/quote-management/page.tsx` (목록)
|
||||
- `src/app/[locale]/(protected)/sales/quote-management/new/page.tsx` (등록)
|
||||
- `src/app/[locale]/(protected)/sales/quote-management/[id]/page.tsx` (상세)
|
||||
- `src/app/[locale]/(protected)/sales/quote-management/[id]/edit/page.tsx` (수정)
|
||||
- `src/components/quotes/QuoteRegistration.tsx` (기존 컴포넌트)
|
||||
|
||||
### 재사용 가능 파일
|
||||
- `src/components/quotes/actions.ts` (API 호출)
|
||||
- `src/components/quotes/QuoteDocument.tsx` (견적서 문서)
|
||||
- `src/components/quotes/types.ts` (타입 정의)
|
||||
|
||||
### 디자인 원칙
|
||||
- 내용/기능: 스크린샷 충실히 구현
|
||||
- 스타일/레이아웃: 기존 프로젝트 패턴 따르기
|
||||
- 색상: 주황색 헤더, 노란색 배경 등 스크린샷 참고
|
||||
|
||||
---
|
||||
|
||||
## 진행 상태
|
||||
- 시작일: 2026-01-12
|
||||
- 현재 상태: 계획 수립 완료
|
||||
@@ -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 전달
|
||||
<IntegratedListTemplateV2
|
||||
filterConfig={filterConfig}
|
||||
filterValues={filterValues}
|
||||
onFilterChange={handleFilterChange}
|
||||
onFilterReset={handleFilterReset}
|
||||
filterTitle="페이지명 필터"
|
||||
// ... 기존 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) |
|
||||
@@ -58,10 +58,23 @@ http://localhost:3000/ko/hr/attendance # 🧪 모바일 출퇴근 (테스트)
|
||||
| 견적관리 | `/ko/sales/quote-management` | ✅ |
|
||||
| 단가관리 | `/ko/sales/pricing-management` | ✅ |
|
||||
|
||||
### 견적 V2 테스트 (새 UI)
|
||||
|
||||
| 페이지 | URL | 상태 |
|
||||
|--------|-----|------|
|
||||
| **견적 등록 (V2)** | `/ko/sales/quote-management/test-new` | 🧪 테스트 |
|
||||
| **견적 상세 (V2)** | `/ko/sales/quote-management/test/1` | 🧪 테스트 |
|
||||
| **견적 수정 (V2)** | `/ko/sales/quote-management/test/1/edit` | 🧪 테스트 |
|
||||
|
||||
```
|
||||
http://localhost:3000/ko/sales/client-management-sales-admin
|
||||
http://localhost:3000/ko/sales/quote-management
|
||||
http://localhost:3000/ko/sales/pricing-management
|
||||
|
||||
# 견적 V2 테스트 (새 UI)
|
||||
http://localhost:3000/ko/sales/quote-management/test-new # 🧪 견적 등록 V2
|
||||
http://localhost:3000/ko/sales/quote-management/test/1 # 🧪 견적 상세 V2
|
||||
http://localhost:3000/ko/sales/quote-management/test/1/edit # 🧪 견적 수정 V2
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# Juil Enterprise Test URLs
|
||||
Last Updated: 2026-01-05
|
||||
Last Updated: 2026-01-12
|
||||
|
||||
### 대시보드
|
||||
| 페이지 | URL | 상태 |
|
||||
@@ -7,10 +7,11 @@ Last Updated: 2026-01-05
|
||||
| **메인 대시보드** | `/ko/construction/dashboard` | ✅ 완료 |
|
||||
|
||||
## 프로젝트 관리 (Project)
|
||||
### 메인
|
||||
|
||||
### 프로젝트관리 (Management)
|
||||
| 페이지 | URL | 상태 |
|
||||
|---|---|---|
|
||||
| **프로젝트 관리 메인** | `/ko/construction/project` | 🚧 구조잡기 |
|
||||
| **프로젝트 관리** | `/ko/construction/project/management` | ✅ 완료 |
|
||||
|
||||
### 입찰관리 (Bidding)
|
||||
| 페이지 | URL | 상태 |
|
||||
@@ -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` | ✅ 완료 |
|
||||
|
||||
@@ -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', // 이미지 업로드를 위한 제한 증가
|
||||
|
||||
104
package-lock.json
generated
104
package-lock.json
generated
@@ -52,6 +52,7 @@
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"vaul": "^1.1.2",
|
||||
"xlsx": "^0.18.5",
|
||||
"zod": "^4.1.12",
|
||||
"zustand": "^5.0.9"
|
||||
},
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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<Awaited<ReturnType<typeof getProgressBillingDetail>>['data']>(undefined);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
getProgressBillingDetail(id)
|
||||
.then(result => {
|
||||
if (result.success && result.data) {
|
||||
setData(result.data);
|
||||
} else {
|
||||
setError('기성청구 정보를 찾을 수 없습니다.');
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
setError('기성청구 정보를 불러오는 중 오류가 발생했습니다.');
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
}, [id]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-muted-foreground">로딩 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !data) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[400px] gap-4">
|
||||
<div className="text-muted-foreground">{error || '기성청구 정보를 찾을 수 없습니다.'}</div>
|
||||
<button
|
||||
onClick={() => router.back()}
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
뒤로 가기
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <ProgressBillingDetailForm mode="edit" billingId={id} initialData={data} />;
|
||||
}
|
||||
@@ -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<Awaited<ReturnType<typeof getProgressBillingDetail>>['data']>(undefined);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
getProgressBillingDetail(id)
|
||||
.then(result => {
|
||||
if (result.success && result.data) {
|
||||
setData(result.data);
|
||||
} else {
|
||||
setError('기성청구 정보를 찾을 수 없습니다.');
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
setError('기성청구 정보를 불러오는 중 오류가 발생했습니다.');
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
}, [id]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-muted-foreground">로딩 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !data) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[400px] gap-4">
|
||||
<div className="text-muted-foreground">{error || '기성청구 정보를 찾을 수 없습니다.'}</div>
|
||||
<button
|
||||
onClick={() => router.back()}
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
뒤로 가기
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <ProgressBillingDetailForm mode="view" billingId={id} initialData={data} />;
|
||||
}
|
||||
@@ -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<ProgressBilling[]>([]);
|
||||
const [stats, setStats] = useState<ProgressBillingStats | undefined>(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 (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-muted-foreground">로딩 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <ProgressBillingManagementListClient initialData={data} initialStats={stats} />;
|
||||
}
|
||||
@@ -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<EstimateDetail | null>(null);
|
||||
|
||||
@@ -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<EstimateDetail | null>(null);
|
||||
|
||||
@@ -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 <ConstructionDetailClient id={id} mode="edit" />;
|
||||
}
|
||||
@@ -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 <ConstructionDetailClient id={id} mode="view" />;
|
||||
}
|
||||
@@ -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<ConstructionManagement[]>([]);
|
||||
const [stats, setStats] = useState<ConstructionManagementStats | undefined>(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 (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-muted-foreground">로딩 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <ConstructionManagementListClient initialData={data} initialStats={stats} />;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import ContractDetailForm from '@/components/business/construction/contract/ContractDetailForm';
|
||||
import { getContractDetail } from '@/components/business/construction/contract';
|
||||
import type { ContractDetail } from '@/components/business/construction/contract/types';
|
||||
|
||||
export default function ContractCreatePage() {
|
||||
const searchParams = useSearchParams();
|
||||
const baseContractId = searchParams.get('baseContractId');
|
||||
|
||||
const [baseData, setBaseData] = useState<ContractDetail | undefined>(undefined);
|
||||
const [isLoading, setIsLoading] = useState(!!baseContractId);
|
||||
|
||||
useEffect(() => {
|
||||
if (baseContractId) {
|
||||
// 변경 계약서 생성: 기존 계약 데이터 복사
|
||||
getContractDetail(baseContractId)
|
||||
.then(result => {
|
||||
if (result.success && result.data) {
|
||||
setBaseData(result.data);
|
||||
}
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
}
|
||||
}, [baseContractId]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-muted-foreground">로딩 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ContractDetailForm
|
||||
mode="create"
|
||||
contractId=""
|
||||
initialData={baseData}
|
||||
isChangeContract={!!baseContractId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,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<Issue | undefined>(undefined);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
try {
|
||||
const result = await getIssue(id);
|
||||
if (result.success && result.data) {
|
||||
setIssue(result.data);
|
||||
} else {
|
||||
setError(result.error || '이슈를 찾을 수 없습니다.');
|
||||
}
|
||||
} catch {
|
||||
setError('이슈 조회에 실패했습니다.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadData();
|
||||
}, [id]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-muted-foreground">로딩 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-red-500">{error}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <IssueDetailForm issue={issue} mode="edit" />;
|
||||
}
|
||||
@@ -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<Issue | undefined>(undefined);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
try {
|
||||
const result = await getIssue(id);
|
||||
if (result.success && result.data) {
|
||||
setIssue(result.data);
|
||||
} else {
|
||||
setError(result.error || '이슈를 찾을 수 없습니다.');
|
||||
}
|
||||
} catch {
|
||||
setError('이슈 조회에 실패했습니다.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadData();
|
||||
}, [id]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-muted-foreground">로딩 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-red-500">{error}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <IssueDetailForm issue={issue} mode="view" />;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import IssueDetailForm from '@/components/business/construction/issue-management/IssueDetailForm';
|
||||
|
||||
export default function IssueNewPage() {
|
||||
return <IssueDetailForm mode="create" />;
|
||||
}
|
||||
@@ -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<Issue[]>([]);
|
||||
const [stats, setStats] = useState<IssueStats | undefined>(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 (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-muted-foreground">로딩 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <IssueManagementListClient initialData={data} initialStats={stats} />;
|
||||
}
|
||||
@@ -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 <ProjectDetailClient projectId={id} />;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { ProjectListClient } from '@/components/business/construction/management';
|
||||
|
||||
export default function ProjectManagementPage() {
|
||||
return <ProjectListClient />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { UtilityManagementListClient } from '@/components/business/construction/utility-management';
|
||||
|
||||
export default function UtilityManagementPage() {
|
||||
return <UtilityManagementListClient />;
|
||||
}
|
||||
@@ -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<WorkerStatus[]>([]);
|
||||
const [stats, setStats] = useState<WorkerStatusStats | undefined>(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 <ContentLoadingSpinner />;
|
||||
}
|
||||
|
||||
return <WorkerStatusListClient initialData={data} initialStats={stats} />;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { ChevronDown, ChevronRight, Check, Search, X } from 'lucide-react';
|
||||
import { ChevronDown, ChevronRight, Check } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { ChecklistCategory, ChecklistSubItem } from '../types';
|
||||
|
||||
interface Day1ChecklistPanelProps {
|
||||
categories: ChecklistCategory[];
|
||||
selectedSubItemId: string | null;
|
||||
searchTerm: string;
|
||||
onSubItemSelect: (categoryId: string, subItemId: string) => void;
|
||||
onSubItemToggle: (categoryId: string, subItemId: string, isCompleted: boolean) => void;
|
||||
}
|
||||
@@ -15,13 +16,13 @@ interface Day1ChecklistPanelProps {
|
||||
export function Day1ChecklistPanel({
|
||||
categories,
|
||||
selectedSubItemId,
|
||||
searchTerm,
|
||||
onSubItemSelect,
|
||||
onSubItemToggle,
|
||||
}: Day1ChecklistPanelProps) {
|
||||
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(
|
||||
new Set(categories.map(c => c.id)) // 기본적으로 모두 펼침
|
||||
);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
|
||||
// 검색 필터링된 카테고리
|
||||
const filteredCategories = useMemo(() => {
|
||||
@@ -74,10 +75,6 @@ export function Day1ChecklistPanel({
|
||||
return { completed, total: originalCategory.subItems.length };
|
||||
};
|
||||
|
||||
const clearSearch = () => {
|
||||
setSearchTerm('');
|
||||
};
|
||||
|
||||
// 검색 결과 하이라이트
|
||||
const highlightText = (text: string, term: string) => {
|
||||
if (!term.trim()) return text;
|
||||
@@ -96,29 +93,9 @@ export function Day1ChecklistPanel({
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg border border-gray-200 overflow-hidden h-full flex flex-col">
|
||||
{/* 헤더 + 검색 */}
|
||||
{/* 헤더 */}
|
||||
<div className="bg-gray-100 px-2 sm:px-4 py-2 sm:py-3 border-b border-gray-200">
|
||||
<h3 className="font-semibold text-gray-900 text-sm sm:text-base mb-2">점검표 항목</h3>
|
||||
{/* 검색 입력 */}
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2 sm:left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="항목 검색..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="w-full pl-8 sm:pl-9 pr-8 py-1.5 sm:py-2 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
/>
|
||||
{searchTerm && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={clearSearch}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 hover:bg-gray-200 rounded-full transition-colors"
|
||||
>
|
||||
<X className="h-4 w-4 text-gray-400" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<h3 className="font-semibold text-gray-900 text-sm sm:text-base">점검표 항목</h3>
|
||||
{/* 검색 결과 카운트 */}
|
||||
{searchTerm && (
|
||||
<div className="mt-1.5 sm:mt-2 text-xs text-gray-500">
|
||||
|
||||
@@ -270,6 +270,16 @@ export default function QualityInspectionPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 공통 필터 (1일차/2일차 모두 사용) */}
|
||||
<Filters
|
||||
selectedYear={selectedYear}
|
||||
selectedQuarter={selectedQuarter}
|
||||
searchTerm={searchTerm}
|
||||
onYearChange={handleYearChange}
|
||||
onQuarterChange={handleQuarterChange}
|
||||
onSearchChange={handleSearchChange}
|
||||
/>
|
||||
|
||||
{activeDay === 1 ? (
|
||||
// ===== 1일차: 기준/매뉴얼 심사 =====
|
||||
<div className="flex-1 grid grid-cols-12 gap-3 sm:gap-4 lg:min-h-0">
|
||||
@@ -284,6 +294,7 @@ export default function QualityInspectionPage() {
|
||||
<Day1ChecklistPanel
|
||||
categories={filteredDay1Categories}
|
||||
selectedSubItemId={selectedSubItemId}
|
||||
searchTerm={searchTerm}
|
||||
onSubItemSelect={handleSubItemSelect}
|
||||
onSubItemToggle={handleSubItemToggle}
|
||||
/>
|
||||
@@ -315,17 +326,7 @@ export default function QualityInspectionPage() {
|
||||
</div>
|
||||
) : (
|
||||
// ===== 2일차: 로트추적 심사 =====
|
||||
<>
|
||||
<Filters
|
||||
selectedYear={selectedYear}
|
||||
selectedQuarter={selectedQuarter}
|
||||
searchTerm={searchTerm}
|
||||
onYearChange={handleYearChange}
|
||||
onQuarterChange={handleQuarterChange}
|
||||
onSearchChange={handleSearchChange}
|
||||
/>
|
||||
|
||||
<div className="flex-1 grid grid-cols-12 gap-3 sm:gap-4 lg:gap-6 lg:min-h-0">
|
||||
<div className="flex-1 grid grid-cols-12 gap-3 sm:gap-4 lg:gap-6 lg:min-h-0">
|
||||
<div className="col-span-12 lg:col-span-3 min-h-[250px] sm:min-h-[300px] lg:min-h-0 lg:h-full overflow-auto lg:overflow-hidden">
|
||||
<ReportList
|
||||
reports={filteredReports}
|
||||
@@ -352,7 +353,6 @@ export default function QualityInspectionPage() {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 설정 패널 */}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* 견적 등록 테스트 페이지 (V2 UI)
|
||||
*
|
||||
* 새로운 자동 견적 산출 UI 테스트용
|
||||
* 기존 견적 등록 페이지는 수정하지 않음
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { QuoteRegistrationV2, QuoteFormDataV2 } from "@/components/quotes/QuoteRegistrationV2";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export default function QuoteTestNewPage() {
|
||||
const router = useRouter();
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
const handleBack = () => {
|
||||
router.push("/sales/quote-management");
|
||||
};
|
||||
|
||||
const handleSave = async (data: QuoteFormDataV2, saveType: "temporary" | "final") => {
|
||||
setIsSaving(true);
|
||||
try {
|
||||
// TODO: API 연동 시 실제 저장 로직 구현
|
||||
console.log("[테스트] 저장 데이터:", data);
|
||||
console.log("[테스트] 저장 타입:", saveType);
|
||||
|
||||
// 테스트용 지연
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
|
||||
toast.success(`[테스트] ${saveType === "temporary" ? "임시" : "최종"} 저장 완료`);
|
||||
|
||||
// 저장 후 상세 페이지로 이동 (테스트용으로 ID=1 사용)
|
||||
if (saveType === "final") {
|
||||
router.push("/sales/quote-management/test/1");
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error("저장 중 오류가 발생했습니다.");
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<QuoteRegistrationV2
|
||||
mode="create"
|
||||
onBack={handleBack}
|
||||
onSave={handleSave}
|
||||
isLoading={isSaving}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* 견적 수정 테스트 페이지 (V2 UI)
|
||||
*
|
||||
* 새로운 자동 견적 산출 UI 테스트용
|
||||
* 기존 견적 수정 페이지는 수정하지 않음
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import { useRouter, useParams } from "next/navigation";
|
||||
import { useState, useEffect } from "react";
|
||||
import { QuoteRegistrationV2, QuoteFormDataV2 } from "@/components/quotes/QuoteRegistrationV2";
|
||||
import { ContentLoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import { toast } from "sonner";
|
||||
|
||||
// 테스트용 목업 데이터
|
||||
const MOCK_DATA: QuoteFormDataV2 = {
|
||||
id: "1",
|
||||
registrationDate: "2026-01-12",
|
||||
writer: "드미트리",
|
||||
clientId: "1",
|
||||
clientName: "아크다이레드",
|
||||
siteName: "강남 테스트 현장",
|
||||
manager: "김담당",
|
||||
contact: "010-1234-5678",
|
||||
dueDate: "2026-02-01",
|
||||
remarks: "테스트 비고 내용입니다.",
|
||||
status: "draft",
|
||||
locations: [
|
||||
{
|
||||
id: "loc-1",
|
||||
floor: "1층",
|
||||
code: "FSS-01",
|
||||
openWidth: 5000,
|
||||
openHeight: 3000,
|
||||
productCode: "KSS01",
|
||||
productName: "방화스크린",
|
||||
quantity: 1,
|
||||
guideRailType: "wall",
|
||||
motorPower: "single",
|
||||
controller: "basic",
|
||||
wingSize: 50,
|
||||
inspectionFee: 50000,
|
||||
unitPrice: 1645200,
|
||||
totalPrice: 1645200,
|
||||
},
|
||||
{
|
||||
id: "loc-2",
|
||||
floor: "3층",
|
||||
code: "FST-30",
|
||||
openWidth: 7500,
|
||||
openHeight: 3300,
|
||||
productCode: "KSS02",
|
||||
productName: "방화스크린2",
|
||||
quantity: 1,
|
||||
guideRailType: "wall",
|
||||
motorPower: "single",
|
||||
controller: "smart",
|
||||
wingSize: 50,
|
||||
inspectionFee: 50000,
|
||||
unitPrice: 2589198,
|
||||
totalPrice: 2589198,
|
||||
},
|
||||
{
|
||||
id: "loc-3",
|
||||
floor: "5층",
|
||||
code: "FSS-50",
|
||||
openWidth: 6000,
|
||||
openHeight: 2800,
|
||||
productCode: "KSS01",
|
||||
productName: "방화스크린",
|
||||
quantity: 2,
|
||||
guideRailType: "floor",
|
||||
motorPower: "three",
|
||||
controller: "premium",
|
||||
wingSize: 50,
|
||||
inspectionFee: 50000,
|
||||
unitPrice: 1721214,
|
||||
totalPrice: 3442428,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export default function QuoteTestEditPage() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const quoteId = params.id as string;
|
||||
|
||||
const [quote, setQuote] = useState<QuoteFormDataV2 | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// 테스트용 데이터 로드 시뮬레이션
|
||||
const loadQuote = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
// 실제로는 API 호출
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
setQuote({ ...MOCK_DATA, id: quoteId });
|
||||
} catch (error) {
|
||||
toast.error("견적 정보를 불러오는데 실패했습니다.");
|
||||
router.push("/sales/quote-management");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadQuote();
|
||||
}, [quoteId, router]);
|
||||
|
||||
const handleBack = () => {
|
||||
router.push("/sales/quote-management");
|
||||
};
|
||||
|
||||
const handleSave = async (data: QuoteFormDataV2, saveType: "temporary" | "final") => {
|
||||
setIsSaving(true);
|
||||
try {
|
||||
// TODO: API 연동 시 실제 저장 로직 구현
|
||||
console.log("[테스트] 수정 데이터:", data);
|
||||
console.log("[테스트] 저장 타입:", saveType);
|
||||
|
||||
// 테스트용 지연
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
|
||||
toast.success(`[테스트] ${saveType === "temporary" ? "임시" : "최종"} 저장 완료`);
|
||||
|
||||
// 저장 후 상세 페이지로 이동
|
||||
if (saveType === "final") {
|
||||
router.push(`/sales/quote-management/test/${quoteId}`);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error("저장 중 오류가 발생했습니다.");
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return <ContentLoadingSpinner text="견적 정보를 불러오는 중..." />;
|
||||
}
|
||||
|
||||
return (
|
||||
<QuoteRegistrationV2
|
||||
mode="edit"
|
||||
onBack={handleBack}
|
||||
onSave={handleSave}
|
||||
initialData={quote}
|
||||
isLoading={isSaving}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* 견적 상세 테스트 페이지 (V2 UI)
|
||||
*
|
||||
* 새로운 자동 견적 산출 UI 테스트용
|
||||
* 기존 견적 상세 페이지는 수정하지 않음
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import { useRouter, useParams } from "next/navigation";
|
||||
import { useState, useEffect } from "react";
|
||||
import { QuoteRegistrationV2, QuoteFormDataV2, LocationItem } from "@/components/quotes/QuoteRegistrationV2";
|
||||
import { ContentLoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import { toast } from "sonner";
|
||||
|
||||
// 테스트용 목업 데이터
|
||||
const MOCK_DATA: QuoteFormDataV2 = {
|
||||
id: "1",
|
||||
registrationDate: "2026-01-12",
|
||||
writer: "드미트리",
|
||||
clientId: "1",
|
||||
clientName: "아크다이레드",
|
||||
siteName: "강남 테스트 현장",
|
||||
manager: "김담당",
|
||||
contact: "010-1234-5678",
|
||||
dueDate: "2026-02-01",
|
||||
remarks: "테스트 비고 내용입니다.",
|
||||
status: "draft",
|
||||
locations: [
|
||||
{
|
||||
id: "loc-1",
|
||||
floor: "1층",
|
||||
code: "FSS-01",
|
||||
openWidth: 5000,
|
||||
openHeight: 3000,
|
||||
productCode: "KSS01",
|
||||
productName: "방화스크린",
|
||||
quantity: 1,
|
||||
guideRailType: "wall",
|
||||
motorPower: "single",
|
||||
controller: "basic",
|
||||
wingSize: 50,
|
||||
inspectionFee: 50000,
|
||||
unitPrice: 1645200,
|
||||
totalPrice: 1645200,
|
||||
},
|
||||
{
|
||||
id: "loc-2",
|
||||
floor: "3층",
|
||||
code: "FST-30",
|
||||
openWidth: 7500,
|
||||
openHeight: 3300,
|
||||
productCode: "KSS02",
|
||||
productName: "방화스크린2",
|
||||
quantity: 1,
|
||||
guideRailType: "wall",
|
||||
motorPower: "single",
|
||||
controller: "smart",
|
||||
wingSize: 50,
|
||||
inspectionFee: 50000,
|
||||
unitPrice: 2589198,
|
||||
totalPrice: 2589198,
|
||||
},
|
||||
{
|
||||
id: "loc-3",
|
||||
floor: "5층",
|
||||
code: "FSS-50",
|
||||
openWidth: 6000,
|
||||
openHeight: 2800,
|
||||
productCode: "KSS01",
|
||||
productName: "방화스크린",
|
||||
quantity: 2,
|
||||
guideRailType: "floor",
|
||||
motorPower: "three",
|
||||
controller: "premium",
|
||||
wingSize: 50,
|
||||
inspectionFee: 50000,
|
||||
unitPrice: 1721214,
|
||||
totalPrice: 3442428,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export default function QuoteTestDetailPage() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const quoteId = params.id as string;
|
||||
|
||||
const [quote, setQuote] = useState<QuoteFormDataV2 | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
// 테스트용 데이터 로드 시뮬레이션
|
||||
const loadQuote = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
// 실제로는 API 호출
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
setQuote({ ...MOCK_DATA, id: quoteId });
|
||||
} catch (error) {
|
||||
toast.error("견적 정보를 불러오는데 실패했습니다.");
|
||||
router.push("/sales/quote-management");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadQuote();
|
||||
}, [quoteId, router]);
|
||||
|
||||
const handleBack = () => {
|
||||
router.push("/sales/quote-management");
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return <ContentLoadingSpinner text="견적 정보를 불러오는 중..." />;
|
||||
}
|
||||
|
||||
return (
|
||||
<QuoteRegistrationV2
|
||||
mode="view"
|
||||
onBack={handleBack}
|
||||
initialData={quote}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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() {
|
||||
/>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* 오늘의 이슈 */}
|
||||
{dashboardSettings.todayIssue.enabled && (
|
||||
<TodayIssueSection
|
||||
items={data.todayIssue}
|
||||
itemSettings={dashboardSettings.todayIssue.items}
|
||||
/>
|
||||
{/* 오늘의 이슈 (새 리스트 형태) */}
|
||||
{dashboardSettings.todayIssueList && (
|
||||
<TodayIssueSection items={data.todayIssueList} />
|
||||
)}
|
||||
|
||||
{/* 일일 일보 */}
|
||||
@@ -230,6 +228,14 @@ export function CEODashboard() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 현황판 (구 오늘의 이슈 - 카드 형태) */}
|
||||
{(dashboardSettings.statusBoard?.enabled ?? dashboardSettings.todayIssue.enabled) && (
|
||||
<StatusBoardSection
|
||||
items={data.todayIssue}
|
||||
itemSettings={dashboardSettings.statusBoard?.items ?? dashboardSettings.todayIssue.items}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 당월 예상 지출 내역 */}
|
||||
{dashboardSettings.monthlyExpense && (
|
||||
<MonthlyExpenseSection
|
||||
|
||||
@@ -35,8 +35,8 @@ import type {
|
||||
} from '../types';
|
||||
import { DEFAULT_DASHBOARD_SETTINGS } from '../types';
|
||||
|
||||
// 오늘의 이슈 항목 라벨
|
||||
const TODAY_ISSUE_LABELS: Record<keyof TodayIssueSettings, string> = {
|
||||
// 현황판 항목 라벨 (구 오늘의 이슈)
|
||||
const STATUS_BOARD_LABELS: Record<keyof TodayIssueSettings, string> = {
|
||||
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({
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-3 p-4">
|
||||
{/* 오늘의 이슈 섹션 */}
|
||||
{/* 오늘의 이슈 (리스트 형태) */}
|
||||
<SectionRow
|
||||
label="오늘의 이슈"
|
||||
checked={localSettings.todayIssueList}
|
||||
onCheckedChange={handleTodayIssueListToggle}
|
||||
/>
|
||||
|
||||
{/* 일일 일보 */}
|
||||
<SectionRow
|
||||
label="일일 일보"
|
||||
checked={localSettings.dailyReport}
|
||||
onCheckedChange={(checked) => handleSectionToggle('dailyReport', checked)}
|
||||
/>
|
||||
|
||||
{/* 현황판 (구 오늘의 이슈 - 카드 형태) */}
|
||||
<div className="space-y-0 rounded-lg overflow-hidden">
|
||||
<div className="flex items-center justify-between py-3 px-4 bg-gray-200">
|
||||
<span className="text-sm font-medium text-gray-800">오늘의 이슈</span>
|
||||
<span className="text-sm font-medium text-gray-800">현황판</span>
|
||||
<ToggleSwitch
|
||||
checked={localSettings.todayIssue.enabled}
|
||||
onCheckedChange={handleTodayIssueToggle}
|
||||
checked={localSettings.statusBoard?.enabled ?? localSettings.todayIssue.enabled}
|
||||
onCheckedChange={handleStatusBoardToggle}
|
||||
/>
|
||||
</div>
|
||||
{localSettings.todayIssue.enabled && (
|
||||
{(localSettings.statusBoard?.enabled ?? localSettings.todayIssue.enabled) && (
|
||||
<div className="bg-gray-50">
|
||||
{(Object.keys(TODAY_ISSUE_LABELS) as Array<keyof TodayIssueSettings>).map(
|
||||
{(Object.keys(STATUS_BOARD_LABELS) as Array<keyof TodayIssueSettings>).map(
|
||||
(key) => (
|
||||
<div
|
||||
key={key}
|
||||
className="flex items-center justify-between py-2.5 px-6 border-t border-gray-200"
|
||||
>
|
||||
<span className="text-sm text-gray-600">
|
||||
{TODAY_ISSUE_LABELS[key]}
|
||||
{STATUS_BOARD_LABELS[key]}
|
||||
</span>
|
||||
<ToggleSwitch
|
||||
checked={localSettings.todayIssue.items[key]}
|
||||
checked={(localSettings.statusBoard?.items ?? localSettings.todayIssue.items)[key]}
|
||||
onCheckedChange={(checked) =>
|
||||
handleTodayIssueItemToggle(key, checked)
|
||||
handleStatusBoardItemToggle(key, checked)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
@@ -313,13 +357,6 @@ export function DashboardSettingsDialog({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 일일 일보 */}
|
||||
<SectionRow
|
||||
label="일일 일보"
|
||||
checked={localSettings.dailyReport}
|
||||
onCheckedChange={(checked) => handleSectionToggle('dailyReport', checked)}
|
||||
/>
|
||||
|
||||
{/* 당월 예상 지출 내역 */}
|
||||
<SectionRow
|
||||
label="당월 예상 지출 내역"
|
||||
|
||||
@@ -15,6 +15,128 @@ export const mockData: CEODashboardData = {
|
||||
{ id: '7', label: '발주', count: 3, path: '/construction/order/order-management', isHighlighted: false },
|
||||
{ id: '8', label: '결재 요청', count: 3, path: '/approval/inbox', isHighlighted: false },
|
||||
],
|
||||
todayIssueList: [
|
||||
{
|
||||
id: 'til1',
|
||||
badge: '수주 성공',
|
||||
content: 'A전자 신규 수주 450,000,000원 확정',
|
||||
time: '10분 전',
|
||||
needsApproval: false,
|
||||
path: '/sales/order-management-sales',
|
||||
},
|
||||
{
|
||||
id: 'til2',
|
||||
badge: '주식 이슈',
|
||||
content: 'B물산 미수금 15,000,000원 연체 15일',
|
||||
time: '1시간 전',
|
||||
needsApproval: false,
|
||||
path: '/accounting/receivables-status',
|
||||
},
|
||||
{
|
||||
id: 'til3',
|
||||
badge: '직정 제고',
|
||||
content: '원자재 3종 안전재고 미달',
|
||||
time: '20시간 전',
|
||||
needsApproval: false,
|
||||
path: '/material/stock-status',
|
||||
},
|
||||
{
|
||||
id: 'til4',
|
||||
badge: '지출예상내역서',
|
||||
content: '품의서명 외 5건 (2,500,000원)',
|
||||
time: '20시간 전',
|
||||
needsApproval: true,
|
||||
path: '/approval/inbox',
|
||||
},
|
||||
{
|
||||
id: 'til5',
|
||||
badge: '세금 신고',
|
||||
content: '4분기 부가세 신고 D-15',
|
||||
time: '20시간 전',
|
||||
needsApproval: false,
|
||||
path: '/accounting/tax',
|
||||
},
|
||||
{
|
||||
id: 'til6',
|
||||
badge: '결재 요청',
|
||||
content: '법인카드 사용 내역 승인 요청 (김철수)',
|
||||
time: '30분 전',
|
||||
needsApproval: true,
|
||||
path: '/approval/inbox',
|
||||
},
|
||||
{
|
||||
id: 'til7',
|
||||
badge: '수주 성공',
|
||||
content: 'C건설 추가 발주 120,000,000원 확정',
|
||||
time: '2시간 전',
|
||||
needsApproval: false,
|
||||
path: '/sales/order-management-sales',
|
||||
},
|
||||
{
|
||||
id: 'til8',
|
||||
badge: '기타',
|
||||
content: '신규 거래처 D산업 등록 완료',
|
||||
time: '3시간 전',
|
||||
needsApproval: false,
|
||||
path: '/accounting/vendors',
|
||||
},
|
||||
{
|
||||
id: 'til9',
|
||||
badge: '결재 요청',
|
||||
content: '출장비 정산 승인 요청 (이영희)',
|
||||
time: '4시간 전',
|
||||
needsApproval: true,
|
||||
path: '/approval/inbox',
|
||||
},
|
||||
{
|
||||
id: 'til10',
|
||||
badge: '주식 이슈',
|
||||
content: 'E물류 미수금 8,500,000원 연체 7일',
|
||||
time: '5시간 전',
|
||||
needsApproval: false,
|
||||
path: '/accounting/receivables-status',
|
||||
},
|
||||
{
|
||||
id: 'til11',
|
||||
badge: '직정 제고',
|
||||
content: '부품 A-102 재고 부족 경고',
|
||||
time: '6시간 전',
|
||||
needsApproval: false,
|
||||
path: '/material/stock-status',
|
||||
},
|
||||
{
|
||||
id: 'til12',
|
||||
badge: '지출예상내역서',
|
||||
content: '장비 구매 품의서 (15,000,000원)',
|
||||
time: '8시간 전',
|
||||
needsApproval: true,
|
||||
path: '/approval/inbox',
|
||||
},
|
||||
{
|
||||
id: 'til13',
|
||||
badge: '수주 성공',
|
||||
content: 'F테크 유지보수 계약 연장 85,000,000원',
|
||||
time: '어제',
|
||||
needsApproval: false,
|
||||
path: '/sales/order-management-sales',
|
||||
},
|
||||
{
|
||||
id: 'til14',
|
||||
badge: '세금 신고',
|
||||
content: '원천세 신고 완료',
|
||||
time: '어제',
|
||||
needsApproval: false,
|
||||
path: '/accounting/tax',
|
||||
},
|
||||
{
|
||||
id: 'til15',
|
||||
badge: '결재 요청',
|
||||
content: '연차 사용 승인 요청 (박지민 외 2명)',
|
||||
time: '어제',
|
||||
needsApproval: true,
|
||||
path: '/hr/vacation-management',
|
||||
},
|
||||
],
|
||||
dailyReport: {
|
||||
date: '2026년 1월 5일 월요일',
|
||||
cards: [
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { SectionTitle, IssueCardItem } from '../components';
|
||||
import type { TodayIssueItem, TodayIssueSettings } from '../types';
|
||||
|
||||
// 라벨 → 설정키 매핑
|
||||
const LABEL_TO_SETTING_KEY: Record<string, keyof TodayIssueSettings> = {
|
||||
'수주': '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 (
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<SectionTitle title="현황판" badge="warning" />
|
||||
|
||||
<div className={`grid ${getGridColsClass()} gap-3`}>
|
||||
{filteredItems.map((item) => (
|
||||
<IssueCardItem
|
||||
key={item.id}
|
||||
label={item.label}
|
||||
count={item.count}
|
||||
subLabel={item.subLabel}
|
||||
isHighlighted={item.isHighlighted}
|
||||
onClick={() => handleItemClick(item.path)}
|
||||
icon={item.icon}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -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<string, keyof TodayIssueSettings> = {
|
||||
'수주': 'orders',
|
||||
'채권 추심': 'debtCollection',
|
||||
'안전 재고': 'safetyStock',
|
||||
'세금 신고': 'taxReport',
|
||||
'신규 업체 등록': 'newVendor',
|
||||
'연차': 'annualLeave',
|
||||
'지각': 'lateness',
|
||||
'결근': 'absence',
|
||||
'발주': 'purchase',
|
||||
'결재 요청': 'approvalRequest',
|
||||
// 뱃지 색상 매핑
|
||||
const BADGE_COLORS: Record<TodayIssueListBadgeType, string> = {
|
||||
'수주 성공': '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<string>('all');
|
||||
const [dismissedIds, setDismissedIds] = useState<Set<string>>(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 (
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<SectionTitle title="오늘의 이슈" badge="warning" />
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold text-gray-900">오늘의 이슈</h2>
|
||||
<Select value={filter} onValueChange={setFilter}>
|
||||
<SelectTrigger className="w-32 h-9">
|
||||
<SelectValue placeholder="전체" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{FILTER_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className={`grid ${getGridColsClass()} gap-3`}>
|
||||
{filteredItems.map((item) => (
|
||||
<IssueCardItem
|
||||
key={item.id}
|
||||
label={item.label}
|
||||
count={item.count}
|
||||
subLabel={item.subLabel}
|
||||
isHighlighted={item.isHighlighted}
|
||||
onClick={() => handleItemClick(item.path)}
|
||||
icon={item.icon}
|
||||
/>
|
||||
))}
|
||||
{/* 리스트 */}
|
||||
<div className="space-y-3 max-h-[400px] overflow-y-auto pr-1">
|
||||
{filteredItems.length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
표시할 이슈가 없습니다.
|
||||
</div>
|
||||
) : (
|
||||
filteredItems.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="flex items-center justify-between p-4 bg-white border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors cursor-pointer"
|
||||
onClick={() => handleItemClick(item)}
|
||||
>
|
||||
{/* 좌측: 뱃지 + 내용 */}
|
||||
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={`shrink-0 ${BADGE_COLORS[item.badge]}`}
|
||||
>
|
||||
{item.badge}
|
||||
</Badge>
|
||||
<span className="text-sm text-gray-800 truncate">
|
||||
{item.content}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 우측: 시간 + 버튼 */}
|
||||
<div className="flex items-center gap-3 shrink-0 ml-4" onClick={(e) => e.stopPropagation()}>
|
||||
<span className="text-xs text-gray-500 whitespace-nowrap">
|
||||
{item.time}
|
||||
</span>
|
||||
{item.needsApproval ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="default"
|
||||
className="h-7 px-3 bg-blue-500 hover:bg-blue-600 text-white text-xs"
|
||||
onClick={() => handleApprove(item)}
|
||||
>
|
||||
승인
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-7 px-3 text-xs"
|
||||
onClick={() => handleReject(item)}
|
||||
>
|
||||
반려
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-7 px-3 text-xs text-gray-600 hover:text-green-600 hover:border-green-600 hover:bg-green-50"
|
||||
onClick={() => handleDismiss(item)}
|
||||
>
|
||||
확인
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export { TodayIssueSection } from './TodayIssueSection';
|
||||
export { StatusBoardSection } from './StatusBoardSection';
|
||||
export { DailyReportSection } from './DailyReportSection';
|
||||
export { MonthlyExpenseSection } from './MonthlyExpenseSection';
|
||||
export { CardManagementSection } from './CardManagementSection';
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -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 = (
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-sm text-muted-foreground whitespace-nowrap">
|
||||
총 {sortedBiddings.length}건
|
||||
</span>
|
||||
|
||||
{/* 거래처 필터 (다중선택) */}
|
||||
<MultiSelectCombobox
|
||||
options={MOCK_PARTNERS}
|
||||
value={partnerFilters}
|
||||
onChange={setPartnerFilters}
|
||||
placeholder="거래처"
|
||||
searchPlaceholder="거래처 검색..."
|
||||
className="w-[120px]"
|
||||
/>
|
||||
|
||||
{/* 입찰자 필터 (다중선택) */}
|
||||
<MultiSelectCombobox
|
||||
options={MOCK_BIDDERS}
|
||||
value={bidderFilters}
|
||||
onChange={setBidderFilters}
|
||||
placeholder="입찰자"
|
||||
searchPlaceholder="입찰자 검색..."
|
||||
className="w-[120px]"
|
||||
/>
|
||||
|
||||
{/* 상태 필터 */}
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<SelectTrigger className="w-[100px]">
|
||||
<SelectValue placeholder="상태" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{BIDDING_STATUS_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* 정렬 */}
|
||||
<Select value={sortBy} onValueChange={setSortBy}>
|
||||
<SelectTrigger className="w-[160px]">
|
||||
<SelectValue placeholder="최신순 (입찰일)" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{BIDDING_SORT_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<IntegratedListTemplateV2
|
||||
@@ -515,7 +526,11 @@ export default function BiddingListClient({ initialData = [], initialStats }: Bi
|
||||
icon={FileText}
|
||||
headerActions={headerActions}
|
||||
stats={statsCardsData}
|
||||
tableHeaderActions={tableHeaderActions}
|
||||
filterConfig={filterConfig}
|
||||
filterValues={filterValues}
|
||||
onFilterChange={handleFilterChange}
|
||||
onFilterReset={handleFilterReset}
|
||||
filterTitle="입찰 필터"
|
||||
searchValue={searchValue}
|
||||
onSearchChange={handleSearchChange}
|
||||
searchPlaceholder="입찰번호, 거래처, 현장명 검색"
|
||||
|
||||
@@ -36,7 +36,7 @@ import {
|
||||
getEmptyContractFormData,
|
||||
contractDetailToFormData,
|
||||
} from './types';
|
||||
import { updateContract, deleteContract } from './actions';
|
||||
import { updateContract, deleteContract, createContract } from './actions';
|
||||
import { downloadFileById } from '@/lib/utils/fileDownload';
|
||||
import { ContractDocumentModal } from './modals/ContractDocumentModal';
|
||||
import {
|
||||
@@ -59,19 +59,22 @@ function formatFileSize(bytes: number): string {
|
||||
}
|
||||
|
||||
interface ContractDetailFormProps {
|
||||
mode: 'view' | 'edit';
|
||||
mode: 'view' | 'edit' | 'create';
|
||||
contractId: string;
|
||||
initialData?: ContractDetail;
|
||||
isChangeContract?: boolean; // 변경 계약서 생성 여부
|
||||
}
|
||||
|
||||
export default function ContractDetailForm({
|
||||
mode,
|
||||
contractId,
|
||||
initialData,
|
||||
isChangeContract = false,
|
||||
}: ContractDetailFormProps) {
|
||||
const router = useRouter();
|
||||
const isViewMode = mode === 'view';
|
||||
const isEditMode = mode === 'edit';
|
||||
const isCreateMode = mode === 'create';
|
||||
|
||||
// 폼 데이터
|
||||
const [formData, setFormData] = useState<ContractFormData>(
|
||||
@@ -121,10 +124,19 @@ export default function ContractDetailForm({
|
||||
router.push(`/ko/construction/project/contract/${contractId}/edit`);
|
||||
}, [router, contractId]);
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
router.push(`/ko/construction/project/contract/${contractId}`);
|
||||
// 변경 계약서 생성 핸들러
|
||||
const handleCreateChangeContract = useCallback(() => {
|
||||
router.push(`/ko/construction/project/contract/create?baseContractId=${contractId}`);
|
||||
}, [router, contractId]);
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
if (isCreateMode) {
|
||||
router.push('/ko/construction/project/contract');
|
||||
} else {
|
||||
router.push(`/ko/construction/project/contract/${contractId}`);
|
||||
}
|
||||
}, [router, contractId, isCreateMode]);
|
||||
|
||||
// 폼 필드 변경
|
||||
const handleFieldChange = useCallback(
|
||||
(field: keyof ContractFormData, value: string | number) => {
|
||||
@@ -141,14 +153,28 @@ export default function ContractDetailForm({
|
||||
const handleConfirmSave = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const result = await updateContract(contractId, formData);
|
||||
if (result.success) {
|
||||
toast.success('수정이 완료되었습니다.');
|
||||
setShowSaveDialog(false);
|
||||
router.push(`/ko/construction/project/contract/${contractId}`);
|
||||
router.refresh();
|
||||
if (isCreateMode) {
|
||||
// 새 계약 생성 (변경 계약서 포함)
|
||||
const result = await createContract(formData);
|
||||
if (result.success && result.data) {
|
||||
toast.success(isChangeContract ? '변경 계약서가 생성되었습니다.' : '계약이 생성되었습니다.');
|
||||
setShowSaveDialog(false);
|
||||
router.push(`/ko/construction/project/contract/${result.data.id}`);
|
||||
router.refresh();
|
||||
} else {
|
||||
toast.error(result.error || '저장에 실패했습니다.');
|
||||
}
|
||||
} else {
|
||||
toast.error(result.error || '저장에 실패했습니다.');
|
||||
// 기존 계약 수정
|
||||
const result = await updateContract(contractId, formData);
|
||||
if (result.success) {
|
||||
toast.success('수정이 완료되었습니다.');
|
||||
setShowSaveDialog(false);
|
||||
router.push(`/ko/construction/project/contract/${contractId}`);
|
||||
router.refresh();
|
||||
} else {
|
||||
toast.error(result.error || '저장에 실패했습니다.');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
@@ -156,7 +182,7 @@ export default function ContractDetailForm({
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [router, contractId, formData]);
|
||||
}, [router, contractId, formData, isCreateMode, isChangeContract]);
|
||||
|
||||
// 삭제 핸들러
|
||||
const handleDelete = useCallback(() => {
|
||||
@@ -280,6 +306,9 @@ export default function ContractDetailForm({
|
||||
// 헤더 액션 버튼
|
||||
const headerActions = isViewMode ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" onClick={handleCreateChangeContract}>
|
||||
변경 계약서 생성
|
||||
</Button>
|
||||
<Button variant="outline" onClick={handleViewDocument}>
|
||||
<Eye className="h-4 w-4 mr-2" />
|
||||
계약서 보기
|
||||
@@ -289,6 +318,15 @@ export default function ContractDetailForm({
|
||||
</Button>
|
||||
<Button onClick={handleEdit}>수정</Button>
|
||||
</div>
|
||||
) : isCreateMode ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" onClick={handleCancel}>
|
||||
취소
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={isLoading}>
|
||||
저장
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" onClick={handleCancel}>
|
||||
@@ -303,10 +341,15 @@ export default function ContractDetailForm({
|
||||
</div>
|
||||
);
|
||||
|
||||
// 페이지 타이틀
|
||||
const pageTitle = isCreateMode
|
||||
? (isChangeContract ? '변경 계약서 생성' : '계약 등록')
|
||||
: '계약 상세';
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
<PageHeader
|
||||
title="계약 상세"
|
||||
title={pageTitle}
|
||||
description="계약 정보를 관리합니다"
|
||||
icon={FileText}
|
||||
onBack={handleBack}
|
||||
@@ -483,8 +526,8 @@ export default function ContractDetailForm({
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{/* 파일 선택 버튼 (수정 모드에서만) */}
|
||||
{isEditMode && (
|
||||
{/* 파일 선택 버튼 (수정/생성 모드에서만) */}
|
||||
{(isEditMode || isCreateMode) && (
|
||||
<Button variant="outline" onClick={handleContractFileSelect}>
|
||||
찾기
|
||||
</Button>
|
||||
@@ -498,7 +541,7 @@ export default function ContractDetailForm({
|
||||
<span className="text-sm font-medium">{formData.contractFile.name}</span>
|
||||
<span className="text-xs text-blue-600">(새 파일)</span>
|
||||
</div>
|
||||
{isEditMode && (
|
||||
{(isEditMode || isCreateMode) && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
@@ -526,7 +569,7 @@ export default function ContractDetailForm({
|
||||
<Download className="h-4 w-4 mr-1" />
|
||||
다운로드
|
||||
</Button>
|
||||
{isEditMode && (
|
||||
{(isEditMode || isCreateMode) && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
@@ -562,7 +605,7 @@ export default function ContractDetailForm({
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{/* 드래그 앤 드롭 영역 */}
|
||||
{isEditMode && (
|
||||
{(isEditMode || isCreateMode) && (
|
||||
<div
|
||||
className={`border-2 border-dashed rounded-lg p-8 text-center mb-4 transition-colors cursor-pointer ${
|
||||
isDragging ? 'border-primary bg-primary/5' : 'border-muted-foreground/25'
|
||||
@@ -605,7 +648,7 @@ export default function ContractDetailForm({
|
||||
<Download className="h-4 w-4 mr-1" />
|
||||
다운로드
|
||||
</Button>
|
||||
{isEditMode && (
|
||||
{(isEditMode || isCreateMode) && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
|
||||
@@ -6,15 +6,8 @@ import { FileText, Clock, CheckCircle, Pencil, Trash2 } 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';
|
||||
@@ -350,6 +343,95 @@ export default function ContractListClient({
|
||||
}
|
||||
}, [selectedItems, loadData]);
|
||||
|
||||
// ===== 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: CONTRACT_STATUS_OPTIONS.filter(o => o.value !== 'all').map(o => ({
|
||||
value: o.value,
|
||||
label: o.label,
|
||||
})),
|
||||
allOptionLabel: '전체',
|
||||
},
|
||||
{
|
||||
key: 'sortBy',
|
||||
label: '정렬',
|
||||
type: 'single',
|
||||
options: CONTRACT_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);
|
||||
}, []);
|
||||
|
||||
// 테이블 행 렌더링
|
||||
// 순서: 체크박스, 번호, 계약번호, 거래처, 현장명, 계약담당자, 공사PM, 총 개소, 계약금액, 계약기간, 상태, 작업
|
||||
const renderTableRow = useCallback(
|
||||
@@ -475,73 +557,6 @@ export default function ContractListClient({
|
||||
},
|
||||
];
|
||||
|
||||
// 테이블 헤더 액션 (총 건수 + 필터들)
|
||||
const tableHeaderActions = (
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-sm text-muted-foreground whitespace-nowrap">
|
||||
총 {sortedContracts.length}건
|
||||
</span>
|
||||
|
||||
{/* 거래처 필터 */}
|
||||
<MultiSelectCombobox
|
||||
options={MOCK_PARTNERS}
|
||||
value={partnerFilters}
|
||||
onChange={setPartnerFilters}
|
||||
placeholder="거래처"
|
||||
searchPlaceholder="거래처 검색..."
|
||||
className="w-[120px]"
|
||||
/>
|
||||
|
||||
{/* 계약담당자 필터 */}
|
||||
<MultiSelectCombobox
|
||||
options={MOCK_CONTRACT_MANAGERS}
|
||||
value={contractManagerFilters}
|
||||
onChange={setContractManagerFilters}
|
||||
placeholder="계약담당자"
|
||||
searchPlaceholder="계약담당자 검색..."
|
||||
className="w-[130px]"
|
||||
/>
|
||||
|
||||
{/* 공사PM 필터 */}
|
||||
<MultiSelectCombobox
|
||||
options={MOCK_CONSTRUCTION_PMS}
|
||||
value={constructionPMFilters}
|
||||
onChange={setConstructionPMFilters}
|
||||
placeholder="공사PM"
|
||||
searchPlaceholder="공사PM 검색..."
|
||||
className="w-[120px]"
|
||||
/>
|
||||
|
||||
{/* 상태 필터 */}
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<SelectTrigger className="w-[100px]">
|
||||
<SelectValue placeholder="상태" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{CONTRACT_STATUS_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* 정렬 */}
|
||||
<Select value={sortBy} onValueChange={setSortBy}>
|
||||
<SelectTrigger className="w-[160px]">
|
||||
<SelectValue placeholder="최신순 (계약일)" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{CONTRACT_SORT_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<IntegratedListTemplateV2
|
||||
@@ -550,7 +565,11 @@ export default function ContractListClient({
|
||||
icon={FileText}
|
||||
headerActions={headerActions}
|
||||
stats={statsCardsData}
|
||||
tableHeaderActions={tableHeaderActions}
|
||||
filterConfig={filterConfig}
|
||||
filterValues={filterValues}
|
||||
onFilterChange={handleFilterChange}
|
||||
onFilterReset={handleFilterReset}
|
||||
filterTitle="계약 필터"
|
||||
searchValue={searchValue}
|
||||
onSearchChange={handleSearchChange}
|
||||
searchPlaceholder="계약번호, 거래처, 현장명 검색"
|
||||
|
||||
@@ -407,4 +407,26 @@ export async function deleteContracts(ids: string[]): Promise<{
|
||||
console.error('계약 일괄 삭제 오류:', error);
|
||||
return { success: false, error: '일괄 삭제에 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// 계약 생성 (변경 계약서 생성 포함)
|
||||
export async function createContract(
|
||||
_data: ContractFormData
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
data?: { id: string };
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
|
||||
// TODO: 실제 API 연동 시 데이터 생성 로직
|
||||
// 새 계약 ID 생성 (목업)
|
||||
const newId = String(MOCK_CONTRACTS.length + 1);
|
||||
|
||||
return { success: true, data: { id: newId } };
|
||||
} catch (error) {
|
||||
console.error('createContract error:', error);
|
||||
return { success: false, error: '계약 생성에 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
@@ -6,15 +6,8 @@ import { FileText, FileTextIcon, Clock, FileCheck, 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';
|
||||
@@ -315,6 +308,81 @@ export default function EstimateListClient({ initialData = [], initialStats }: E
|
||||
}
|
||||
}, [selectedItems, loadData]);
|
||||
|
||||
// ===== filterConfig 기반 통합 필터 시스템 =====
|
||||
const filterConfig: FilterFieldConfig[] = useMemo(() => [
|
||||
{
|
||||
key: 'partner',
|
||||
label: '거래처',
|
||||
type: 'multi',
|
||||
options: MOCK_PARTNERS.map(o => ({
|
||||
value: o.value,
|
||||
label: o.label,
|
||||
})),
|
||||
},
|
||||
{
|
||||
key: 'estimator',
|
||||
label: '견적자',
|
||||
type: 'multi',
|
||||
options: MOCK_ESTIMATORS.map(o => ({
|
||||
value: o.value,
|
||||
label: o.label,
|
||||
})),
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
label: '상태',
|
||||
type: 'single',
|
||||
options: ESTIMATE_STATUS_OPTIONS.filter(o => o.value !== 'all').map(o => ({
|
||||
value: o.value,
|
||||
label: o.label,
|
||||
})),
|
||||
allOptionLabel: '전체',
|
||||
},
|
||||
{
|
||||
key: 'sortBy',
|
||||
label: '정렬',
|
||||
type: 'single',
|
||||
options: ESTIMATE_SORT_OPTIONS.map(o => ({
|
||||
value: o.value,
|
||||
label: o.label,
|
||||
})),
|
||||
allOptionLabel: '최신순',
|
||||
},
|
||||
], []);
|
||||
|
||||
const filterValues: FilterValues = useMemo(() => ({
|
||||
partner: partnerFilters,
|
||||
estimator: estimatorFilters,
|
||||
status: statusFilter,
|
||||
sortBy: sortBy,
|
||||
}), [partnerFilters, estimatorFilters, statusFilter, sortBy]);
|
||||
|
||||
const handleFilterChange = useCallback((key: string, value: string | string[]) => {
|
||||
switch (key) {
|
||||
case 'partner':
|
||||
setPartnerFilters(value as string[]);
|
||||
break;
|
||||
case 'estimator':
|
||||
setEstimatorFilters(value as string[]);
|
||||
break;
|
||||
case 'status':
|
||||
setStatusFilter(value as string);
|
||||
break;
|
||||
case 'sortBy':
|
||||
setSortBy(value as string);
|
||||
break;
|
||||
}
|
||||
setCurrentPage(1);
|
||||
}, []);
|
||||
|
||||
const handleFilterReset = useCallback(() => {
|
||||
setPartnerFilters([]);
|
||||
setEstimatorFilters([]);
|
||||
setStatusFilter('all');
|
||||
setSortBy('latest');
|
||||
setCurrentPage(1);
|
||||
}, []);
|
||||
|
||||
// 테이블 행 렌더링
|
||||
const renderTableRow = useCallback(
|
||||
(estimate: Estimate, index: number, globalIndex: number) => {
|
||||
@@ -428,63 +496,6 @@ export default function EstimateListClient({ initialData = [], initialStats }: E
|
||||
},
|
||||
];
|
||||
|
||||
// 테이블 헤더 액션 (총 건수 + 필터들)
|
||||
const tableHeaderActions = (
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-sm text-muted-foreground whitespace-nowrap">
|
||||
총 {sortedEstimates.length}건
|
||||
</span>
|
||||
|
||||
{/* 거래처 필터 (다중선택) */}
|
||||
<MultiSelectCombobox
|
||||
options={MOCK_PARTNERS}
|
||||
value={partnerFilters}
|
||||
onChange={setPartnerFilters}
|
||||
placeholder="거래처"
|
||||
searchPlaceholder="거래처 검색..."
|
||||
className="w-[120px]"
|
||||
/>
|
||||
|
||||
{/* 견적자 필터 (다중선택) */}
|
||||
<MultiSelectCombobox
|
||||
options={MOCK_ESTIMATORS}
|
||||
value={estimatorFilters}
|
||||
onChange={setEstimatorFilters}
|
||||
placeholder="견적자"
|
||||
searchPlaceholder="견적자 검색..."
|
||||
className="w-[120px]"
|
||||
/>
|
||||
|
||||
{/* 상태 필터 */}
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<SelectTrigger className="w-[110px]">
|
||||
<SelectValue placeholder="상태" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ESTIMATE_STATUS_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* 정렬 */}
|
||||
<Select value={sortBy} onValueChange={setSortBy}>
|
||||
<SelectTrigger className="w-[140px]">
|
||||
<SelectValue placeholder="최신순" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ESTIMATE_SORT_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<IntegratedListTemplateV2
|
||||
@@ -493,7 +504,11 @@ export default function EstimateListClient({ initialData = [], initialStats }: E
|
||||
icon={FileText}
|
||||
headerActions={headerActions}
|
||||
stats={statsCardsData}
|
||||
tableHeaderActions={tableHeaderActions}
|
||||
filterConfig={filterConfig}
|
||||
filterValues={filterValues}
|
||||
onFilterChange={handleFilterChange}
|
||||
onFilterReset={handleFilterReset}
|
||||
filterTitle="견적 필터"
|
||||
searchValue={searchValue}
|
||||
onSearchChange={handleSearchChange}
|
||||
searchPlaceholder="견적번호, 거래처, 현장명 검색"
|
||||
|
||||
@@ -88,6 +88,8 @@ export function EstimateDocumentModal({
|
||||
address: '주소',
|
||||
amount: formData.summaryItems.reduce((sum, item) => sum + item.totalCost, 0),
|
||||
date: formData.bidInfo.bidDate || '2025년 12월 12일',
|
||||
manager: formData.estimateCompanyManager || '',
|
||||
managerContact: formData.estimateCompanyManagerContact || '',
|
||||
contact: {
|
||||
hp: '010-3679-2188',
|
||||
tel: '(02) 849-5130',
|
||||
@@ -194,17 +196,22 @@ export function EstimateDocumentModal({
|
||||
<td className="border border-gray-400 px-3 py-2">{documentData.date}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="border border-gray-400 px-3 py-2 bg-gray-100 text-center">연락처</td>
|
||||
<td className="border border-gray-400 px-3 py-2 bg-gray-100 text-center">담당자</td>
|
||||
<td className="border border-gray-400 px-3 py-2"></td>
|
||||
<td className="border border-gray-400 px-3 py-2 bg-gray-100 text-center">연락처</td>
|
||||
<td className="border border-gray-400 px-3 py-2">
|
||||
<td className="border border-gray-400 px-3 py-2 bg-gray-100 text-center" rowSpan={2}>연락처</td>
|
||||
<td className="border border-gray-400 px-3 py-2" rowSpan={2}>
|
||||
<div className="space-y-0.5 text-xs">
|
||||
<div>담당자 : {documentData.manager}</div>
|
||||
<div>H . P : {documentData.contact.hp}</div>
|
||||
<div>T E L : {documentData.contact.tel}</div>
|
||||
<div>F A X : {documentData.contact.fax}</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="border border-gray-400 px-3 py-2 bg-gray-100 text-center">연락처</td>
|
||||
<td className="border border-gray-400 px-3 py-2"></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
|
||||
@@ -53,29 +53,46 @@ export function EstimateInfoSection({
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">견적 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium text-gray-700">견적번호</Label>
|
||||
<Input value={formData.estimateCode} disabled className="bg-gray-50" />
|
||||
<CardContent className="space-y-4">
|
||||
{/* 1행: 견적번호, 견적자 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium text-gray-700">견적번호</Label>
|
||||
<Input value={formData.estimateCode} disabled className="bg-gray-50" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium text-gray-700">견적자</Label>
|
||||
<Input value={formData.estimatorName} disabled className="bg-gray-50" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium text-gray-700">견적자</Label>
|
||||
<Input value={formData.estimatorName} disabled className="bg-gray-50" />
|
||||
{/* 2행: 견적 회사 담당자, 견적 회사 담당자 연락처 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium text-gray-700">견적 회사 담당자</Label>
|
||||
<Input value={formData.estimateCompanyManager} disabled className="bg-gray-50" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium text-gray-700">견적 회사 담당자 연락처</Label>
|
||||
<Input value={formData.estimateCompanyManagerContact} disabled className="bg-gray-50" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium text-gray-700">견적금액</Label>
|
||||
<Input
|
||||
value={formatAmount(formData.estimateAmount)}
|
||||
disabled
|
||||
className="bg-gray-50 text-right"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium text-gray-700">상태</Label>
|
||||
<div className="flex items-center h-10 px-3 border rounded-md bg-gray-50">
|
||||
<span className={STATUS_STYLES[formData.status]}>
|
||||
{STATUS_LABELS[formData.status]}
|
||||
</span>
|
||||
{/* 3행: 견적금액, 상태 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium text-gray-700">견적금액</Label>
|
||||
<Input
|
||||
value={formatAmount(formData.estimateAmount)}
|
||||
disabled
|
||||
className="bg-gray-50 text-right"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium text-gray-700">상태</Label>
|
||||
<div className="flex items-center h-10 px-3 border rounded-md bg-gray-50">
|
||||
<span className={STATUS_STYLES[formData.status]}>
|
||||
{STATUS_LABELS[formData.status]}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
@@ -78,9 +78,11 @@ export function PriceAdjustmentSection({
|
||||
>
|
||||
전체 적용
|
||||
</Button>
|
||||
{/* 초기화 버튼 주석처리
|
||||
<Button type="button" variant="outline" size="sm" onClick={onReset}>
|
||||
초기화
|
||||
</Button>
|
||||
*/}
|
||||
</div>
|
||||
)}
|
||||
</CardHeader>
|
||||
|
||||
@@ -184,6 +184,8 @@ export interface EstimateDetailFormData {
|
||||
estimateCode: string;
|
||||
estimatorId: string;
|
||||
estimatorName: string;
|
||||
estimateCompanyManager: string; // 견적 회사 담당자
|
||||
estimateCompanyManagerContact: string; // 견적 회사 담당자 연락처
|
||||
estimateAmount: number;
|
||||
status: EstimateStatus;
|
||||
|
||||
@@ -251,6 +253,8 @@ export function getEmptyEstimateDetailFormData(): EstimateDetailFormData {
|
||||
estimateCode: '',
|
||||
estimatorId: '',
|
||||
estimatorName: '',
|
||||
estimateCompanyManager: '',
|
||||
estimateCompanyManagerContact: '',
|
||||
estimateAmount: 0,
|
||||
status: 'pending',
|
||||
siteBriefing: {
|
||||
@@ -290,6 +294,8 @@ export function estimateDetailToFormData(detail: EstimateDetail): EstimateDetail
|
||||
estimateCode: detail.estimateCode,
|
||||
estimatorId: detail.estimatorId,
|
||||
estimatorName: detail.estimatorName,
|
||||
estimateCompanyManager: detail.estimateCompanyManager || '',
|
||||
estimateCompanyManagerContact: detail.estimateCompanyManagerContact || '',
|
||||
estimateAmount: detail.estimateAmount,
|
||||
status: detail.status,
|
||||
siteBriefing: detail.siteBriefing,
|
||||
@@ -315,6 +321,8 @@ export interface Estimate {
|
||||
projectName: string; // 현장명
|
||||
estimatorId: string; // 견적자 ID
|
||||
estimatorName: string; // 견적자명
|
||||
estimateCompanyManager: string; // 견적 회사 담당자
|
||||
estimateCompanyManagerContact: string; // 견적 회사 담당자 연락처
|
||||
|
||||
// 견적 정보
|
||||
itemCount: number; // 총 개소 (품목 수)
|
||||
|
||||
@@ -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 = (
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-sm text-muted-foreground whitespace-nowrap">
|
||||
총 {sortedReports.length}건
|
||||
</span>
|
||||
|
||||
{/* 거래처 필터 */}
|
||||
<MultiSelectCombobox
|
||||
options={MOCK_PARTNERS}
|
||||
value={partnerFilters}
|
||||
onChange={setPartnerFilters}
|
||||
placeholder="거래처"
|
||||
searchPlaceholder="거래처 검색..."
|
||||
className="w-[130px]"
|
||||
/>
|
||||
|
||||
{/* 계약담당자 필터 */}
|
||||
<MultiSelectCombobox
|
||||
options={MOCK_CONTRACT_MANAGERS}
|
||||
value={contractManagerFilters}
|
||||
onChange={setContractManagerFilters}
|
||||
placeholder="계약담당자"
|
||||
searchPlaceholder="계약담당자 검색..."
|
||||
className="w-[130px]"
|
||||
/>
|
||||
|
||||
{/* 공사PM 필터 */}
|
||||
<MultiSelectCombobox
|
||||
options={MOCK_CONSTRUCTION_PMS}
|
||||
value={constructionPMFilters}
|
||||
onChange={setConstructionPMFilters}
|
||||
placeholder="공사PM"
|
||||
searchPlaceholder="공사PM 검색..."
|
||||
className="w-[120px]"
|
||||
/>
|
||||
|
||||
{/* 상태 필터 */}
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<SelectTrigger className="w-[120px]">
|
||||
<SelectValue placeholder="상태" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{REPORT_STATUS_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* 정렬 */}
|
||||
<Select value={sortBy} onValueChange={setSortBy}>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue placeholder="최신순 (계약시작일)" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{REPORT_SORT_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<IntegratedListTemplateV2
|
||||
@@ -464,7 +479,11 @@ export default function HandoverReportListClient({
|
||||
icon={FileText}
|
||||
headerActions={headerActions}
|
||||
stats={statsCardsData}
|
||||
tableHeaderActions={tableHeaderActions}
|
||||
filterConfig={filterConfig}
|
||||
filterValues={filterValues}
|
||||
onFilterChange={handleFilterChange}
|
||||
onFilterReset={handleFilterReset}
|
||||
filterTitle="인수인계보고서 필터"
|
||||
searchValue={searchValue}
|
||||
onSearchChange={handleSearchChange}
|
||||
searchPlaceholder="보고서번호, 거래처, 현장명 검색"
|
||||
|
||||
@@ -0,0 +1,693 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback, useRef, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { AlertTriangle, List, Mic, X, Undo2, Upload } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { PageLayout } from '@/components/organisms/PageLayout';
|
||||
import { PageHeader } from '@/components/organisms/PageHeader';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { toast } from 'sonner';
|
||||
import type { Issue, IssueFormData, IssueImage, IssueStatus, IssueCategory, IssuePriority } from './types';
|
||||
import {
|
||||
ISSUE_STATUS_FORM_OPTIONS,
|
||||
ISSUE_PRIORITY_FORM_OPTIONS,
|
||||
ISSUE_CATEGORY_FORM_OPTIONS,
|
||||
MOCK_CONSTRUCTION_NUMBERS,
|
||||
MOCK_ISSUE_PARTNERS,
|
||||
MOCK_ISSUE_SITES,
|
||||
MOCK_ISSUE_REPORTERS,
|
||||
MOCK_ISSUE_ASSIGNEES,
|
||||
} from './types';
|
||||
import { createIssue, updateIssue, withdrawIssue } from './actions';
|
||||
|
||||
interface IssueDetailFormProps {
|
||||
issue?: Issue;
|
||||
mode?: 'view' | 'edit' | 'create';
|
||||
}
|
||||
|
||||
export default function IssueDetailForm({ issue, mode = 'view' }: IssueDetailFormProps) {
|
||||
const router = useRouter();
|
||||
const isEditMode = mode === 'edit';
|
||||
const isCreateMode = mode === 'create';
|
||||
const isViewMode = mode === 'view';
|
||||
|
||||
// 이미지 업로드 ref
|
||||
const imageInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// 철회 다이얼로그
|
||||
const [withdrawDialogOpen, setWithdrawDialogOpen] = useState(false);
|
||||
|
||||
// 폼 상태
|
||||
const [formData, setFormData] = useState<IssueFormData>({
|
||||
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<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||
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<HTMLInputElement>) => {
|
||||
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 (
|
||||
<PageLayout>
|
||||
<PageHeader
|
||||
title={isCreateMode ? '이슈 등록' : '이슈 상세'}
|
||||
description="이슈를 등록하고 관리합니다"
|
||||
icon={AlertTriangle}
|
||||
actions={
|
||||
isViewMode ? (
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => router.push('/ko/construction/project/issue-management')}
|
||||
>
|
||||
<List className="mr-2 h-4 w-4" />
|
||||
목록
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setWithdrawDialogOpen(true)}
|
||||
className="text-orange-600 border-orange-300 hover:bg-orange-50"
|
||||
>
|
||||
<Undo2 className="mr-2 h-4 w-4" />
|
||||
철회
|
||||
</Button>
|
||||
<Button onClick={handleEditClick}>수정</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => router.push('/ko/construction/project/issue-management')}
|
||||
>
|
||||
<List className="mr-2 h-4 w-4" />
|
||||
목록
|
||||
</Button>
|
||||
<Button variant="outline" onClick={handleCancel}>
|
||||
취소
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} disabled={isSubmitting}>
|
||||
{isSubmitting ? '저장 중...' : '저장'}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* 이슈 정보 카드 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>이슈 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* 이슈번호 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="issueNumber">이슈번호</Label>
|
||||
<Input
|
||||
id="issueNumber"
|
||||
value={formData.issueNumber || (isCreateMode ? '자동 생성' : '')}
|
||||
disabled
|
||||
className="bg-muted"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 시공번호 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="constructionNumber">시공번호</Label>
|
||||
<Select
|
||||
value={formData.constructionNumber}
|
||||
onValueChange={handleSelectChange('constructionNumber')}
|
||||
disabled={isReadOnly}
|
||||
>
|
||||
<SelectTrigger id="constructionNumber">
|
||||
<SelectValue placeholder="시공번호 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{MOCK_CONSTRUCTION_NUMBERS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 거래처 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="partnerName">거래처</Label>
|
||||
<Select
|
||||
value={formData.partnerName}
|
||||
onValueChange={handleSelectChange('partnerName')}
|
||||
disabled={isReadOnly}
|
||||
>
|
||||
<SelectTrigger id="partnerName">
|
||||
<SelectValue placeholder="거래처 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{MOCK_ISSUE_PARTNERS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.label}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 현장 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="siteName">현장</Label>
|
||||
<Select
|
||||
value={formData.siteName}
|
||||
onValueChange={handleSelectChange('siteName')}
|
||||
disabled={isReadOnly}
|
||||
>
|
||||
<SelectTrigger id="siteName">
|
||||
<SelectValue placeholder="현장 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{MOCK_ISSUE_SITES.map((option) => (
|
||||
<SelectItem key={option.value} value={option.label}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 공사PM (자동) */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="constructionPM">공사PM</Label>
|
||||
<Input
|
||||
id="constructionPM"
|
||||
value={formData.constructionPM}
|
||||
disabled
|
||||
className="bg-muted"
|
||||
placeholder="시공번호 선택 시 자동 입력"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 공사담당자 (자동) */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="constructionManagers">공사담당자</Label>
|
||||
<Input
|
||||
id="constructionManagers"
|
||||
value={formData.constructionManagers}
|
||||
disabled
|
||||
className="bg-muted"
|
||||
placeholder="시공번호 선택 시 자동 입력"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 보고자 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="reporter">보고자</Label>
|
||||
<Select
|
||||
value={formData.reporter}
|
||||
onValueChange={handleSelectChange('reporter')}
|
||||
disabled={isReadOnly}
|
||||
>
|
||||
<SelectTrigger id="reporter">
|
||||
<SelectValue placeholder="보고자 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{MOCK_ISSUE_REPORTERS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.label}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 담당자 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="assignee">담당자</Label>
|
||||
<Select
|
||||
value={formData.assignee}
|
||||
onValueChange={handleAssigneeChange}
|
||||
disabled={isReadOnly}
|
||||
>
|
||||
<SelectTrigger id="assignee">
|
||||
<SelectValue placeholder="담당자 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{MOCK_ISSUE_ASSIGNEES.map((option) => (
|
||||
<SelectItem key={option.value} value={option.label}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 이슈보고일 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="reportDate">이슈보고일</Label>
|
||||
<Input
|
||||
id="reportDate"
|
||||
type="date"
|
||||
value={formData.reportDate}
|
||||
onChange={handleInputChange('reportDate')}
|
||||
disabled={isReadOnly}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 이슈해결일 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="resolvedDate">이슈해결일</Label>
|
||||
<Input
|
||||
id="resolvedDate"
|
||||
type="date"
|
||||
value={formData.resolvedDate}
|
||||
onChange={handleInputChange('resolvedDate')}
|
||||
disabled={isReadOnly}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 상태 */}
|
||||
<div className="space-y-2 md:col-span-2">
|
||||
<Label htmlFor="status">상태</Label>
|
||||
<Select
|
||||
value={formData.status}
|
||||
onValueChange={(value) => handleSelectChange('status')(value as IssueStatus)}
|
||||
disabled={isReadOnly}
|
||||
>
|
||||
<SelectTrigger id="status" className="w-full md:w-[200px]">
|
||||
<SelectValue placeholder="상태 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ISSUE_STATUS_FORM_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 이슈 보고 카드 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>이슈 보고</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{/* 구분 & 중요도 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* 구분 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="category">구분</Label>
|
||||
<Select
|
||||
value={formData.category}
|
||||
onValueChange={(value) => handleSelectChange('category')(value as IssueCategory)}
|
||||
disabled={isReadOnly}
|
||||
>
|
||||
<SelectTrigger id="category">
|
||||
<SelectValue placeholder="구분 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ISSUE_CATEGORY_FORM_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 중요도 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="priority">중요도</Label>
|
||||
<Select
|
||||
value={formData.priority}
|
||||
onValueChange={handlePriorityChange}
|
||||
disabled={isReadOnly}
|
||||
>
|
||||
<SelectTrigger id="priority">
|
||||
<SelectValue placeholder="중요도 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ISSUE_PRIORITY_FORM_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 제목 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="title">제목</Label>
|
||||
<Input
|
||||
id="title"
|
||||
value={formData.title}
|
||||
onChange={handleInputChange('title')}
|
||||
placeholder="제목을 입력하세요"
|
||||
disabled={isReadOnly}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 내용 */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="content">내용</Label>
|
||||
{!isReadOnly && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleRecordClick}
|
||||
>
|
||||
<Mic className="mr-2 h-4 w-4" />
|
||||
녹음
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<Textarea
|
||||
id="content"
|
||||
value={formData.content}
|
||||
onChange={handleInputChange('content')}
|
||||
placeholder="내용을 입력하세요"
|
||||
rows={6}
|
||||
disabled={isReadOnly}
|
||||
/>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 사진 카드 */}
|
||||
<Card>
|
||||
<CardHeader className="border-b pb-4">
|
||||
<CardTitle className="text-base font-medium">사진</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-4 space-y-4">
|
||||
{/* 업로드 버튼 */}
|
||||
{!isReadOnly && (
|
||||
<div>
|
||||
<label className="inline-flex items-center gap-2 px-4 py-2 border rounded-md cursor-pointer hover:bg-muted/50 transition-colors">
|
||||
<Upload className="h-4 w-4" />
|
||||
<span className="text-sm">사진 업로드</span>
|
||||
<input
|
||||
ref={imageInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
onChange={handleImageUpload}
|
||||
className="hidden"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 업로드된 사진 목록 */}
|
||||
{formData.images.length > 0 ? (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
{formData.images.map((image) => (
|
||||
<div key={image.id} className="relative group">
|
||||
<img
|
||||
src={image.url}
|
||||
alt={image.fileName}
|
||||
className="w-full h-32 object-cover rounded-lg border"
|
||||
/>
|
||||
{!isReadOnly && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleImageRemove(image.id)}
|
||||
className="absolute top-1 right-1 p-1 bg-black/50 text-white rounded-full opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
<div className="text-xs text-muted-foreground truncate mt-1">
|
||||
{image.fileName}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center text-muted-foreground py-4">
|
||||
업로드된 사진이 없습니다.
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 철회 확인 다이얼로그 */}
|
||||
<AlertDialog open={withdrawDialogOpen} onOpenChange={setWithdrawDialogOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>이슈 철회</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
이 이슈를 철회하시겠습니까?
|
||||
<br />
|
||||
철회된 이슈는 복구할 수 없습니다.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>취소</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleWithdraw}
|
||||
className="bg-orange-600 hover:bg-orange-700"
|
||||
>
|
||||
철회
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,679 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useMemo, useCallback, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { AlertTriangle, Pencil, Plus, Inbox, Clock, CheckCircle, XCircle, Undo2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { TableCell, TableRow } from '@/components/ui/table';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
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 {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { toast } from 'sonner';
|
||||
import type {
|
||||
Issue,
|
||||
IssueStats,
|
||||
} from './types';
|
||||
import {
|
||||
ISSUE_STATUS_OPTIONS,
|
||||
ISSUE_PRIORITY_OPTIONS,
|
||||
ISSUE_CATEGORY_OPTIONS,
|
||||
ISSUE_SORT_OPTIONS,
|
||||
ISSUE_STATUS_STYLES,
|
||||
ISSUE_STATUS_LABELS,
|
||||
ISSUE_PRIORITY_STYLES,
|
||||
ISSUE_PRIORITY_LABELS,
|
||||
ISSUE_CATEGORY_LABELS,
|
||||
MOCK_ISSUE_PARTNERS,
|
||||
MOCK_ISSUE_SITES,
|
||||
MOCK_ISSUE_REPORTERS,
|
||||
MOCK_ISSUE_ASSIGNEES,
|
||||
} from './types';
|
||||
import {
|
||||
getIssueList,
|
||||
getIssueStats,
|
||||
withdrawIssues,
|
||||
} from './actions';
|
||||
|
||||
// 테이블 컬럼 정의
|
||||
// 체크박스, 번호, 이슈번호, 시공번호, 거래처, 현장, 구분, 제목, 보고자, 이슈보고일, 이슈해결일, 담당자, 중요도, 상태, 작업
|
||||
const tableColumns: TableColumn[] = [
|
||||
{ key: 'no', label: '번호', className: 'w-[50px] text-center' },
|
||||
{ key: 'issueNumber', label: '이슈번호', className: 'w-[120px]' },
|
||||
{ key: 'constructionNumber', label: '시공번호', className: 'w-[100px]' },
|
||||
{ key: 'partnerName', label: '거래처', className: 'w-[100px]' },
|
||||
{ key: 'siteName', label: '현장', className: 'min-w-[120px]' },
|
||||
{ key: 'category', label: '구분', className: 'w-[80px] text-center' },
|
||||
{ key: 'title', label: '제목', className: 'min-w-[150px]' },
|
||||
{ key: 'reporter', label: '보고자', className: 'w-[80px]' },
|
||||
{ key: 'reportDate', label: '이슈보고일', className: 'w-[100px]' },
|
||||
{ key: 'resolvedDate', label: '이슈해결일', className: 'w-[100px]' },
|
||||
{ key: 'assignee', label: '담당자', className: 'w-[80px]' },
|
||||
{ key: 'priority', label: '중요도', className: 'w-[80px] text-center' },
|
||||
{ key: 'status', label: '상태', className: 'w-[80px] text-center' },
|
||||
{ key: 'actions', label: '작업', className: 'w-[80px] text-center' },
|
||||
];
|
||||
|
||||
interface IssueManagementListClientProps {
|
||||
initialData?: Issue[];
|
||||
initialStats?: IssueStats;
|
||||
}
|
||||
|
||||
export default function IssueManagementListClient({
|
||||
initialData = [],
|
||||
initialStats,
|
||||
}: IssueManagementListClientProps) {
|
||||
const router = useRouter();
|
||||
|
||||
// 상태
|
||||
const [issues, setIssues] = useState<Issue[]>(initialData);
|
||||
const [stats, setStats] = useState<IssueStats | null>(initialStats || null);
|
||||
const [searchValue, setSearchValue] = useState('');
|
||||
// 다중선택 필터 (빈 배열 = 전체)
|
||||
const [partnerFilters, setPartnerFilters] = useState<string[]>([]);
|
||||
const [siteFilters, setSiteFilters] = useState<string[]>([]);
|
||||
const [categoryFilters, setCategoryFilters] = useState<string[]>([]);
|
||||
const [reporterFilters, setReporterFilters] = useState<string[]>([]);
|
||||
const [assigneeFilters, setAssigneeFilters] = useState<string[]>([]);
|
||||
// 단일선택 필터
|
||||
const [priorityFilter, setPriorityFilter] = useState<string>('all');
|
||||
const [statusFilter, setStatusFilter] = useState<string>('all');
|
||||
const [sortBy, setSortBy] = useState<string>('latest');
|
||||
const [startDate, setStartDate] = useState<string>('');
|
||||
const [endDate, setEndDate] = useState<string>('');
|
||||
const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set());
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [activeStatTab, setActiveStatTab] = useState<'all' | 'received' | 'in_progress' | 'resolved' | 'unresolved'>('all');
|
||||
const [withdrawDialogOpen, setWithdrawDialogOpen] = useState(false);
|
||||
const itemsPerPage = 20;
|
||||
|
||||
// 데이터 로드
|
||||
const loadData = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const [listResult, statsResult] = await Promise.all([
|
||||
getIssueList({
|
||||
size: 1000,
|
||||
startDate: startDate || undefined,
|
||||
endDate: endDate || undefined,
|
||||
}),
|
||||
getIssueStats(),
|
||||
]);
|
||||
|
||||
if (listResult.success && listResult.data) {
|
||||
setIssues(listResult.data.items);
|
||||
}
|
||||
if (statsResult.success && statsResult.data) {
|
||||
setStats(statsResult.data);
|
||||
}
|
||||
} catch {
|
||||
toast.error('데이터 로드에 실패했습니다.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [startDate, endDate]);
|
||||
|
||||
// 초기 데이터가 없으면 로드
|
||||
useEffect(() => {
|
||||
if (initialData.length === 0) {
|
||||
loadData();
|
||||
}
|
||||
}, [initialData.length, loadData]);
|
||||
|
||||
// 다중선택 필터 옵션 변환 (MultiSelectCombobox용)
|
||||
const partnerOptions: MultiSelectOption[] = useMemo(() =>
|
||||
MOCK_ISSUE_PARTNERS.map(p => ({ value: p.value, label: p.label })),
|
||||
[]);
|
||||
const siteOptions: MultiSelectOption[] = useMemo(() =>
|
||||
MOCK_ISSUE_SITES.map(s => ({ value: s.value, label: s.label })),
|
||||
[]);
|
||||
const categoryOptions: MultiSelectOption[] = useMemo(() =>
|
||||
ISSUE_CATEGORY_OPTIONS.filter(c => c.value !== 'all').map(c => ({ value: c.value, label: c.label })),
|
||||
[]);
|
||||
const reporterOptions: MultiSelectOption[] = useMemo(() =>
|
||||
MOCK_ISSUE_REPORTERS.map(r => ({ value: r.value, label: r.label })),
|
||||
[]);
|
||||
const assigneeOptions: MultiSelectOption[] = useMemo(() =>
|
||||
MOCK_ISSUE_ASSIGNEES.map(a => ({ value: a.value, label: a.label })),
|
||||
[]);
|
||||
|
||||
// 필터링된 데이터
|
||||
const filteredIssues = useMemo(() => {
|
||||
return issues.filter((item) => {
|
||||
// 상태 탭 필터
|
||||
if (activeStatTab !== 'all' && item.status !== activeStatTab) return false;
|
||||
|
||||
// 상태 필터
|
||||
if (statusFilter !== 'all' && item.status !== statusFilter) return false;
|
||||
|
||||
// 중요도 필터
|
||||
if (priorityFilter !== 'all' && item.priority !== priorityFilter) return false;
|
||||
|
||||
// 거래처 필터 (다중선택)
|
||||
if (partnerFilters.length > 0) {
|
||||
const matchingPartner = MOCK_ISSUE_PARTNERS.find((p) => p.label === item.partnerName);
|
||||
if (!matchingPartner || !partnerFilters.includes(matchingPartner.value)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 현장 필터 (다중선택)
|
||||
if (siteFilters.length > 0) {
|
||||
const matchingSite = MOCK_ISSUE_SITES.find((s) => s.label === item.siteName);
|
||||
if (!matchingSite || !siteFilters.includes(matchingSite.value)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 구분 필터 (다중선택)
|
||||
if (categoryFilters.length > 0) {
|
||||
if (!categoryFilters.includes(item.category)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 보고자 필터 (다중선택)
|
||||
if (reporterFilters.length > 0) {
|
||||
const matchingReporter = MOCK_ISSUE_REPORTERS.find((r) => r.label === item.reporter);
|
||||
if (!matchingReporter || !reporterFilters.includes(matchingReporter.value)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 담당자 필터 (다중선택)
|
||||
if (assigneeFilters.length > 0) {
|
||||
const matchingAssignee = MOCK_ISSUE_ASSIGNEES.find((a) => a.label === item.assignee);
|
||||
if (!matchingAssignee || !assigneeFilters.includes(matchingAssignee.value)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 검색 필터
|
||||
if (searchValue) {
|
||||
const search = searchValue.toLowerCase();
|
||||
return (
|
||||
item.issueNumber.toLowerCase().includes(search) ||
|
||||
item.constructionNumber.toLowerCase().includes(search) ||
|
||||
item.partnerName.toLowerCase().includes(search) ||
|
||||
item.siteName.toLowerCase().includes(search) ||
|
||||
item.title.toLowerCase().includes(search) ||
|
||||
item.reporter.toLowerCase().includes(search) ||
|
||||
item.assignee.toLowerCase().includes(search)
|
||||
);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}, [issues, activeStatTab, statusFilter, priorityFilter, partnerFilters, siteFilters, categoryFilters, reporterFilters, assigneeFilters, searchValue]);
|
||||
|
||||
// 정렬
|
||||
const sortedIssues = useMemo(() => {
|
||||
const sorted = [...filteredIssues];
|
||||
switch (sortBy) {
|
||||
case 'latest':
|
||||
sorted.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
||||
break;
|
||||
case 'oldest':
|
||||
sorted.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
|
||||
break;
|
||||
case 'reportDate':
|
||||
sorted.sort((a, b) => new Date(b.reportDate).getTime() - new Date(a.reportDate).getTime());
|
||||
break;
|
||||
case 'priorityHigh':
|
||||
const priorityOrder: Record<string, number> = { urgent: 0, normal: 1 };
|
||||
sorted.sort((a, b) => priorityOrder[a.priority] - priorityOrder[b.priority]);
|
||||
break;
|
||||
case 'priorityLow':
|
||||
const priorityOrderLow: Record<string, number> = { urgent: 1, normal: 0 };
|
||||
sorted.sort((a, b) => priorityOrderLow[a.priority] - priorityOrderLow[b.priority]);
|
||||
break;
|
||||
}
|
||||
return sorted;
|
||||
}, [filteredIssues, sortBy]);
|
||||
|
||||
// 페이지네이션
|
||||
const totalPages = Math.ceil(sortedIssues.length / itemsPerPage);
|
||||
const paginatedData = useMemo(() => {
|
||||
const start = (currentPage - 1) * itemsPerPage;
|
||||
return sortedIssues.slice(start, start + itemsPerPage);
|
||||
}, [sortedIssues, currentPage, itemsPerPage]);
|
||||
|
||||
// 핸들러
|
||||
const handleSearchChange = useCallback((value: string) => {
|
||||
setSearchValue(value);
|
||||
setCurrentPage(1);
|
||||
}, []);
|
||||
|
||||
const handleToggleSelection = useCallback((id: string) => {
|
||||
setSelectedItems((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(id)) {
|
||||
newSet.delete(id);
|
||||
} else {
|
||||
newSet.add(id);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleToggleSelectAll = useCallback(() => {
|
||||
if (selectedItems.size === paginatedData.length) {
|
||||
setSelectedItems(new Set());
|
||||
} else {
|
||||
setSelectedItems(new Set(paginatedData.map((c) => c.id)));
|
||||
}
|
||||
}, [selectedItems.size, paginatedData]);
|
||||
|
||||
const handleRowClick = useCallback(
|
||||
(item: Issue) => {
|
||||
router.push(`/ko/construction/project/issue-management/${item.id}`);
|
||||
},
|
||||
[router]
|
||||
);
|
||||
|
||||
const handleEdit = useCallback(
|
||||
(e: React.MouseEvent, itemId: string) => {
|
||||
e.stopPropagation();
|
||||
router.push(`/ko/construction/project/issue-management/${itemId}/edit`);
|
||||
},
|
||||
[router]
|
||||
);
|
||||
|
||||
const handleCreateIssue = useCallback(() => {
|
||||
router.push('/ko/construction/project/issue-management/new');
|
||||
}, [router]);
|
||||
|
||||
// 철회 다이얼로그 열기
|
||||
const handleWithdrawClick = useCallback(() => {
|
||||
if (selectedItems.size === 0) {
|
||||
toast.error('철회할 이슈를 선택해주세요.');
|
||||
return;
|
||||
}
|
||||
setWithdrawDialogOpen(true);
|
||||
}, [selectedItems.size]);
|
||||
|
||||
// 철회 실행
|
||||
const handleWithdraw = useCallback(async () => {
|
||||
try {
|
||||
const ids = Array.from(selectedItems);
|
||||
const result = await withdrawIssues(ids);
|
||||
if (result.success) {
|
||||
toast.success(`${ids.length}건의 이슈가 철회되었습니다.`);
|
||||
setSelectedItems(new Set());
|
||||
loadData();
|
||||
} else {
|
||||
toast.error(result.error || '이슈 철회에 실패했습니다.');
|
||||
}
|
||||
} catch {
|
||||
toast.error('이슈 철회에 실패했습니다.');
|
||||
} finally {
|
||||
setWithdrawDialogOpen(false);
|
||||
}
|
||||
}, [selectedItems, loadData]);
|
||||
|
||||
// 날짜 포맷
|
||||
const formatDate = (dateStr: string | null) => {
|
||||
if (!dateStr) return '-';
|
||||
return dateStr.split('T')[0];
|
||||
};
|
||||
|
||||
// 테이블 행 렌더링
|
||||
const renderTableRow = useCallback(
|
||||
(item: Issue, index: number, globalIndex: number) => {
|
||||
const isSelected = selectedItems.has(item.id);
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
key={item.id}
|
||||
className="cursor-pointer hover:bg-muted/50"
|
||||
onClick={() => handleRowClick(item)}
|
||||
>
|
||||
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={() => handleToggleSelection(item.id)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-center text-muted-foreground">{globalIndex}</TableCell>
|
||||
<TableCell>{item.issueNumber}</TableCell>
|
||||
<TableCell>{item.constructionNumber}</TableCell>
|
||||
<TableCell>{item.partnerName}</TableCell>
|
||||
<TableCell>{item.siteName}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<span className="px-2 py-1 rounded-full text-xs font-medium bg-gray-100 text-gray-700">
|
||||
{ISSUE_CATEGORY_LABELS[item.category]}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="max-w-[200px] truncate" title={item.title}>{item.title}</TableCell>
|
||||
<TableCell>{item.reporter}</TableCell>
|
||||
<TableCell>{formatDate(item.reportDate)}</TableCell>
|
||||
<TableCell>{formatDate(item.resolvedDate)}</TableCell>
|
||||
<TableCell>{item.assignee}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<span className={`px-2 py-1 rounded-full text-xs font-medium ${ISSUE_PRIORITY_STYLES[item.priority]}`}>
|
||||
{ISSUE_PRIORITY_LABELS[item.priority]}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<span className={`px-2 py-1 rounded-full text-xs font-medium ${ISSUE_STATUS_STYLES[item.status]}`}>
|
||||
{ISSUE_STATUS_LABELS[item.status]}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{isSelected && (
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={(e) => handleEdit(e, item.id)}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
},
|
||||
[selectedItems, handleToggleSelection, handleRowClick, handleEdit]
|
||||
);
|
||||
|
||||
// 모바일 카드 렌더링
|
||||
const renderMobileCard = useCallback(
|
||||
(item: Issue, index: number, globalIndex: number, isSelected: boolean, onToggle: () => void) => {
|
||||
return (
|
||||
<MobileCard
|
||||
title={item.title}
|
||||
subtitle={item.issueNumber}
|
||||
badge={ISSUE_STATUS_LABELS[item.status]}
|
||||
badgeVariant="secondary"
|
||||
isSelected={isSelected}
|
||||
onToggle={onToggle}
|
||||
onClick={() => handleRowClick(item)}
|
||||
details={[
|
||||
{ label: '거래처', value: item.partnerName },
|
||||
{ label: '현장', value: item.siteName },
|
||||
{ label: '보고일', value: formatDate(item.reportDate) },
|
||||
{ label: '중요도', value: ISSUE_PRIORITY_LABELS[item.priority] },
|
||||
]}
|
||||
/>
|
||||
);
|
||||
},
|
||||
[handleRowClick]
|
||||
);
|
||||
|
||||
// 헤더 액션 (DateRangeSelector + 이슈 등록 버튼)
|
||||
const headerActions = (
|
||||
<DateRangeSelector
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
onStartDateChange={setStartDate}
|
||||
onEndDateChange={setEndDate}
|
||||
extraActions={
|
||||
<Button onClick={handleCreateIssue}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
이슈 등록
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
// 통계 카드 클릭 핸들러
|
||||
const handleStatClick = useCallback((tab: 'all' | 'received' | 'in_progress' | 'resolved' | 'unresolved') => {
|
||||
setActiveStatTab(tab);
|
||||
setCurrentPage(1);
|
||||
}, []);
|
||||
|
||||
// 통계 카드 데이터
|
||||
const statsCardsData: StatCard[] = [
|
||||
{
|
||||
label: '접수',
|
||||
value: stats?.received ?? 0,
|
||||
icon: Inbox,
|
||||
iconColor: 'text-blue-600',
|
||||
onClick: () => handleStatClick('received'),
|
||||
isActive: activeStatTab === 'received',
|
||||
},
|
||||
{
|
||||
label: '처리중',
|
||||
value: stats?.inProgress ?? 0,
|
||||
icon: Clock,
|
||||
iconColor: 'text-yellow-600',
|
||||
onClick: () => handleStatClick('in_progress'),
|
||||
isActive: activeStatTab === 'in_progress',
|
||||
},
|
||||
{
|
||||
label: '해결완료',
|
||||
value: stats?.resolved ?? 0,
|
||||
icon: CheckCircle,
|
||||
iconColor: 'text-green-600',
|
||||
onClick: () => handleStatClick('resolved'),
|
||||
isActive: activeStatTab === 'resolved',
|
||||
},
|
||||
{
|
||||
label: '미해결',
|
||||
value: stats?.unresolved ?? 0,
|
||||
icon: XCircle,
|
||||
iconColor: 'text-red-600',
|
||||
onClick: () => handleStatClick('unresolved'),
|
||||
isActive: activeStatTab === 'unresolved',
|
||||
},
|
||||
];
|
||||
|
||||
// ===== filterConfig 기반 통합 필터 시스템 =====
|
||||
const filterConfig: FilterFieldConfig[] = useMemo(() => [
|
||||
{
|
||||
key: 'partner',
|
||||
label: '거래처',
|
||||
type: 'multi',
|
||||
options: partnerOptions.map(o => ({
|
||||
value: o.value,
|
||||
label: o.label,
|
||||
})),
|
||||
},
|
||||
{
|
||||
key: 'site',
|
||||
label: '현장명',
|
||||
type: 'multi',
|
||||
options: siteOptions.map(o => ({
|
||||
value: o.value,
|
||||
label: o.label,
|
||||
})),
|
||||
},
|
||||
{
|
||||
key: 'category',
|
||||
label: '구분',
|
||||
type: 'multi',
|
||||
options: categoryOptions.map(o => ({
|
||||
value: o.value,
|
||||
label: o.label,
|
||||
})),
|
||||
},
|
||||
{
|
||||
key: 'reporter',
|
||||
label: '보고자',
|
||||
type: 'multi',
|
||||
options: reporterOptions.map(o => ({
|
||||
value: o.value,
|
||||
label: o.label,
|
||||
})),
|
||||
},
|
||||
{
|
||||
key: 'assignee',
|
||||
label: '담당자',
|
||||
type: 'multi',
|
||||
options: assigneeOptions.map(o => ({
|
||||
value: o.value,
|
||||
label: o.label,
|
||||
})),
|
||||
},
|
||||
{
|
||||
key: 'priority',
|
||||
label: '중요도',
|
||||
type: 'single',
|
||||
options: ISSUE_PRIORITY_OPTIONS.filter(o => o.value !== 'all').map(o => ({
|
||||
value: o.value,
|
||||
label: o.label,
|
||||
})),
|
||||
allOptionLabel: '전체',
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
label: '상태',
|
||||
type: 'single',
|
||||
options: ISSUE_STATUS_OPTIONS.filter(o => o.value !== 'all').map(o => ({
|
||||
value: o.value,
|
||||
label: o.label,
|
||||
})),
|
||||
allOptionLabel: '전체',
|
||||
},
|
||||
{
|
||||
key: 'sortBy',
|
||||
label: '정렬',
|
||||
type: 'single',
|
||||
options: ISSUE_SORT_OPTIONS.map(o => ({
|
||||
value: o.value,
|
||||
label: o.label,
|
||||
})),
|
||||
allOptionLabel: '최신순',
|
||||
},
|
||||
], [partnerOptions, siteOptions, categoryOptions, reporterOptions, assigneeOptions]);
|
||||
|
||||
const filterValues: FilterValues = useMemo(() => ({
|
||||
partner: partnerFilters,
|
||||
site: siteFilters,
|
||||
category: categoryFilters,
|
||||
reporter: reporterFilters,
|
||||
assignee: assigneeFilters,
|
||||
priority: priorityFilter,
|
||||
status: statusFilter,
|
||||
sortBy: sortBy,
|
||||
}), [partnerFilters, siteFilters, categoryFilters, reporterFilters, assigneeFilters, priorityFilter, statusFilter, sortBy]);
|
||||
|
||||
const handleFilterChange = useCallback((key: string, value: string | string[]) => {
|
||||
switch (key) {
|
||||
case 'partner':
|
||||
setPartnerFilters(value as string[]);
|
||||
break;
|
||||
case 'site':
|
||||
setSiteFilters(value as string[]);
|
||||
break;
|
||||
case 'category':
|
||||
setCategoryFilters(value as string[]);
|
||||
break;
|
||||
case 'reporter':
|
||||
setReporterFilters(value as string[]);
|
||||
break;
|
||||
case 'assignee':
|
||||
setAssigneeFilters(value as string[]);
|
||||
break;
|
||||
case 'priority':
|
||||
setPriorityFilter(value as string);
|
||||
break;
|
||||
case 'status':
|
||||
setStatusFilter(value as string);
|
||||
break;
|
||||
case 'sortBy':
|
||||
setSortBy(value as string);
|
||||
break;
|
||||
}
|
||||
setCurrentPage(1);
|
||||
}, []);
|
||||
|
||||
const handleFilterReset = useCallback(() => {
|
||||
setPartnerFilters([]);
|
||||
setSiteFilters([]);
|
||||
setCategoryFilters([]);
|
||||
setReporterFilters([]);
|
||||
setAssigneeFilters([]);
|
||||
setPriorityFilter('all');
|
||||
setStatusFilter('all');
|
||||
setSortBy('latest');
|
||||
setCurrentPage(1);
|
||||
}, []);
|
||||
|
||||
// 철회 버튼 (bulkActions용)
|
||||
const bulkActions = selectedItems.size > 0 ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleWithdrawClick}
|
||||
className="text-orange-600 border-orange-300 hover:bg-orange-50"
|
||||
>
|
||||
<Undo2 className="mr-2 h-4 w-4" />
|
||||
철회 ({selectedItems.size})
|
||||
</Button>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<IntegratedListTemplateV2
|
||||
title="이슈관리"
|
||||
description="이슈 목록을 관리합니다"
|
||||
icon={AlertTriangle}
|
||||
headerActions={headerActions}
|
||||
stats={statsCardsData}
|
||||
filterConfig={filterConfig}
|
||||
filterValues={filterValues}
|
||||
onFilterChange={handleFilterChange}
|
||||
onFilterReset={handleFilterReset}
|
||||
filterTitle="이슈 필터"
|
||||
bulkActions={bulkActions}
|
||||
searchValue={searchValue}
|
||||
onSearchChange={handleSearchChange}
|
||||
searchPlaceholder="이슈번호, 시공번호, 거래처, 현장, 제목, 보고자, 담당자 검색"
|
||||
tableColumns={tableColumns}
|
||||
data={paginatedData}
|
||||
allData={sortedIssues}
|
||||
getItemId={(item) => item.id}
|
||||
renderTableRow={renderTableRow}
|
||||
renderMobileCard={renderMobileCard}
|
||||
selectedItems={selectedItems}
|
||||
onToggleSelection={handleToggleSelection}
|
||||
onToggleSelectAll={handleToggleSelectAll}
|
||||
pagination={{
|
||||
currentPage,
|
||||
totalPages,
|
||||
totalItems: sortedIssues.length,
|
||||
itemsPerPage,
|
||||
onPageChange: setCurrentPage,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 철회 확인 다이얼로그 */}
|
||||
<AlertDialog open={withdrawDialogOpen} onOpenChange={setWithdrawDialogOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>이슈 철회</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
선택한 {selectedItems.size}건의 이슈를 철회하시겠습니까?
|
||||
<br />
|
||||
철회된 이슈는 복구할 수 없습니다.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>취소</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleWithdraw}
|
||||
className="bg-orange-600 hover:bg-orange-700"
|
||||
>
|
||||
철회
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
417
src/components/business/construction/issue-management/actions.ts
Normal file
417
src/components/business/construction/issue-management/actions.ts
Normal file
@@ -0,0 +1,417 @@
|
||||
'use server';
|
||||
|
||||
import type {
|
||||
Issue,
|
||||
IssueStats,
|
||||
IssueFilter,
|
||||
IssueListResponse,
|
||||
} from './types';
|
||||
|
||||
/**
|
||||
* 이슈관리 Server Actions
|
||||
*/
|
||||
|
||||
// Mock 이슈 데이터
|
||||
const mockIssues: Issue[] = [
|
||||
{
|
||||
id: '1',
|
||||
issueNumber: 'ISS-2025-001',
|
||||
constructionNumber: 'CON-001',
|
||||
partnerName: '대한건설',
|
||||
siteName: '서울 강남 현장',
|
||||
constructionPM: '홍길동',
|
||||
constructionManagers: '홍길동, 김철수, 이영희',
|
||||
category: 'material',
|
||||
title: '자재 품질 불량',
|
||||
content: '납품된 철근 일부에 녹이 발생하여 품질 검수가 필요합니다.',
|
||||
reporter: '홍길동',
|
||||
reportDate: '2025-09-01',
|
||||
resolvedDate: '2025-09-03',
|
||||
assignee: '김과장',
|
||||
priority: 'urgent',
|
||||
status: 'resolved',
|
||||
description: '납품된 철근 일부에 녹이 발생',
|
||||
createdAt: '2025-09-01T09:00:00Z',
|
||||
updatedAt: '2025-09-03T15:00:00Z',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
issueNumber: 'ISS-2025-002',
|
||||
constructionNumber: 'CON-002',
|
||||
partnerName: '삼성시공',
|
||||
siteName: '부산 해운대 현장',
|
||||
constructionPM: '김철수',
|
||||
constructionManagers: '김철수, 박민수',
|
||||
category: 'safety',
|
||||
title: '안전장비 미착용',
|
||||
content: '현장 작업자 안전모 미착용 발견되어 시정 조치가 필요합니다.',
|
||||
reporter: '김철수',
|
||||
reportDate: '2025-09-02',
|
||||
resolvedDate: null,
|
||||
assignee: '이부장',
|
||||
priority: 'urgent',
|
||||
status: 'in_progress',
|
||||
description: '현장 작업자 안전모 미착용 발견',
|
||||
createdAt: '2025-09-02T10:00:00Z',
|
||||
updatedAt: '2025-09-02T10:00:00Z',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
issueNumber: 'ISS-2025-003',
|
||||
constructionNumber: 'CON-001',
|
||||
partnerName: '대한건설',
|
||||
siteName: '서울 강남 현장',
|
||||
constructionPM: '홍길동',
|
||||
constructionManagers: '홍길동, 김철수, 이영희',
|
||||
category: 'process',
|
||||
title: '공정 지연',
|
||||
content: '우천으로 인한 외부 공사가 지연되고 있습니다.',
|
||||
reporter: '이영희',
|
||||
reportDate: '2025-09-03',
|
||||
resolvedDate: null,
|
||||
assignee: '박대리',
|
||||
priority: 'normal',
|
||||
status: 'received',
|
||||
description: '우천으로 인한 외부 공사 지연',
|
||||
createdAt: '2025-09-03T08:00:00Z',
|
||||
updatedAt: '2025-09-03T08:00:00Z',
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
issueNumber: 'ISS-2025-004',
|
||||
constructionNumber: 'CON-003',
|
||||
partnerName: 'LG건설',
|
||||
siteName: '대전 유성 현장',
|
||||
constructionPM: '이영희',
|
||||
constructionManagers: '이영희, 최대리',
|
||||
category: 'etc',
|
||||
title: '예산 초과 우려',
|
||||
content: '자재비 상승으로 인한 예산 초과가 예상됩니다.',
|
||||
reporter: '박민수',
|
||||
reportDate: '2025-09-01',
|
||||
resolvedDate: null,
|
||||
assignee: '정차장',
|
||||
priority: 'normal',
|
||||
status: 'unresolved',
|
||||
description: '자재비 상승으로 인한 예산 초과 예상',
|
||||
createdAt: '2025-09-01T11:00:00Z',
|
||||
updatedAt: '2025-09-01T11:00:00Z',
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
issueNumber: 'ISS-2025-005',
|
||||
constructionNumber: 'CON-004',
|
||||
partnerName: '현대건설',
|
||||
siteName: '인천 송도 현장',
|
||||
constructionPM: '박민수',
|
||||
constructionManagers: '박민수, 홍길동',
|
||||
category: 'etc',
|
||||
title: '민원 발생',
|
||||
content: '인근 주민으로부터 소음 민원이 접수되었습니다.',
|
||||
reporter: '최대리',
|
||||
reportDate: '2025-09-02',
|
||||
resolvedDate: '2025-09-02',
|
||||
assignee: '송이사',
|
||||
priority: 'normal',
|
||||
status: 'resolved',
|
||||
description: '소음 민원 접수',
|
||||
createdAt: '2025-09-02T14:00:00Z',
|
||||
updatedAt: '2025-09-02T18:00:00Z',
|
||||
},
|
||||
{
|
||||
id: '6',
|
||||
issueNumber: 'ISS-2025-006',
|
||||
constructionNumber: 'CON-002',
|
||||
partnerName: '삼성시공',
|
||||
siteName: '부산 해운대 현장',
|
||||
constructionPM: '김철수',
|
||||
constructionManagers: '김철수, 박민수',
|
||||
category: 'material',
|
||||
title: '시공 품질 미달',
|
||||
content: '콘크리트 타설 품질이 기준에 미달합니다.',
|
||||
reporter: '홍길동',
|
||||
reportDate: '2025-09-03',
|
||||
resolvedDate: null,
|
||||
assignee: '김과장',
|
||||
priority: 'urgent',
|
||||
status: 'received',
|
||||
description: '콘크리트 타설 품질 기준 미달',
|
||||
createdAt: '2025-09-03T09:30:00Z',
|
||||
updatedAt: '2025-09-03T09:30:00Z',
|
||||
},
|
||||
{
|
||||
id: '7',
|
||||
issueNumber: 'ISS-2025-007',
|
||||
constructionNumber: 'CON-005',
|
||||
partnerName: 'SK건설',
|
||||
siteName: '광주 북구 현장',
|
||||
constructionPM: '최대리',
|
||||
constructionManagers: '최대리, 김철수, 이영희',
|
||||
category: 'safety',
|
||||
title: '장비 점검 필요',
|
||||
content: '크레인 정기 점검 시기가 도래하여 점검이 필요합니다.',
|
||||
reporter: '김철수',
|
||||
reportDate: '2025-09-01',
|
||||
resolvedDate: null,
|
||||
assignee: '이부장',
|
||||
priority: 'normal',
|
||||
status: 'in_progress',
|
||||
description: '크레인 정기 점검 시기 도래',
|
||||
createdAt: '2025-09-01T13:00:00Z',
|
||||
updatedAt: '2025-09-02T10:00:00Z',
|
||||
},
|
||||
{
|
||||
id: '8',
|
||||
issueNumber: 'ISS-2025-008',
|
||||
constructionNumber: 'CON-001',
|
||||
partnerName: '대한건설',
|
||||
siteName: '서울 강남 현장',
|
||||
constructionPM: '홍길동',
|
||||
constructionManagers: '홍길동, 김철수, 이영희',
|
||||
category: 'process',
|
||||
title: '인력 부족',
|
||||
content: '숙련공 부족으로 공사 진행에 어려움이 있습니다.',
|
||||
reporter: '이영희',
|
||||
reportDate: '2025-09-02',
|
||||
resolvedDate: null,
|
||||
assignee: '박대리',
|
||||
priority: 'urgent',
|
||||
status: 'in_progress',
|
||||
description: '숙련공 부족으로 공사 진행 어려움',
|
||||
createdAt: '2025-09-02T08:30:00Z',
|
||||
updatedAt: '2025-09-03T09:00:00Z',
|
||||
},
|
||||
// 추가 더미 데이터
|
||||
...Array.from({ length: 47 }, (_, i) => ({
|
||||
id: `${i + 9}`,
|
||||
issueNumber: `ISS-2025-${String(i + 9).padStart(3, '0')}`,
|
||||
constructionNumber: `CON-${String((i % 5) + 1).padStart(3, '0')}`,
|
||||
partnerName: ['대한건설', '삼성시공', 'LG건설', '현대건설', 'SK건설'][i % 5],
|
||||
siteName: ['서울 강남 현장', '부산 해운대 현장', '대전 유성 현장', '인천 송도 현장', '광주 북구 현장'][i % 5],
|
||||
constructionPM: ['홍길동', '김철수', '이영희', '박민수', '최대리'][i % 5],
|
||||
constructionManagers: ['홍길동, 김철수', '김철수, 박민수', '이영희, 최대리', '박민수, 홍길동', '최대리, 김철수'][i % 5],
|
||||
category: (['material', 'drawing', 'process', 'safety', 'etc'] as const)[i % 5],
|
||||
title: `이슈 ${i + 9}`,
|
||||
content: `이슈 ${i + 9}에 대한 상세 내용입니다.`,
|
||||
reporter: ['홍길동', '김철수', '이영희', '박민수', '최대리'][i % 5],
|
||||
reportDate: `2025-09-${String((i % 28) + 1).padStart(2, '0')}`,
|
||||
resolvedDate: i % 3 === 0 ? `2025-09-${String(Math.min((i % 28) + 3, 30)).padStart(2, '0')}` : null,
|
||||
assignee: ['김과장', '이부장', '박대리', '정차장', '송이사'][i % 5],
|
||||
priority: (['urgent', 'normal'] as const)[i % 2],
|
||||
status: (['received', 'in_progress', 'resolved', 'unresolved'] as const)[i % 4],
|
||||
description: `이슈 설명 ${i + 9}`,
|
||||
createdAt: `2025-09-${String((i % 28) + 1).padStart(2, '0')}T09:00:00Z`,
|
||||
updatedAt: `2025-09-${String((i % 28) + 1).padStart(2, '0')}T09:00:00Z`,
|
||||
})),
|
||||
];
|
||||
|
||||
// 이슈 목록 조회
|
||||
export async function getIssueList(
|
||||
filter?: IssueFilter
|
||||
): Promise<{ success: boolean; data?: IssueListResponse; error?: string }> {
|
||||
try {
|
||||
let filtered = [...mockIssues];
|
||||
|
||||
// 거래처 필터 (다중선택)
|
||||
if (filter?.partners && filter.partners.length > 0) {
|
||||
filtered = filtered.filter((issue) =>
|
||||
filter.partners!.some((p) => issue.partnerName.includes(p) || p.includes(issue.partnerName))
|
||||
);
|
||||
}
|
||||
|
||||
// 현장 필터 (다중선택)
|
||||
if (filter?.sites && filter.sites.length > 0) {
|
||||
filtered = filtered.filter((issue) =>
|
||||
filter.sites!.some((s) => issue.siteName.includes(s) || s.includes(issue.siteName))
|
||||
);
|
||||
}
|
||||
|
||||
// 구분 필터 (다중선택)
|
||||
if (filter?.categories && filter.categories.length > 0) {
|
||||
filtered = filtered.filter((issue) =>
|
||||
filter.categories!.includes(issue.category)
|
||||
);
|
||||
}
|
||||
|
||||
// 보고자 필터 (다중선택)
|
||||
if (filter?.reporters && filter.reporters.length > 0) {
|
||||
filtered = filtered.filter((issue) =>
|
||||
filter.reporters!.some((r) => issue.reporter.includes(r) || r.includes(issue.reporter))
|
||||
);
|
||||
}
|
||||
|
||||
// 담당자 필터 (다중선택)
|
||||
if (filter?.assignees && filter.assignees.length > 0) {
|
||||
filtered = filtered.filter((issue) =>
|
||||
filter.assignees!.some((a) => issue.assignee.includes(a) || a.includes(issue.assignee))
|
||||
);
|
||||
}
|
||||
|
||||
// 중요도 필터 (단일선택)
|
||||
if (filter?.priority && filter.priority !== 'all') {
|
||||
filtered = filtered.filter((issue) => issue.priority === filter.priority);
|
||||
}
|
||||
|
||||
// 상태 필터 (단일선택)
|
||||
if (filter?.status && filter.status !== 'all') {
|
||||
filtered = filtered.filter((issue) => issue.status === filter.status);
|
||||
}
|
||||
|
||||
// 날짜 필터
|
||||
if (filter?.startDate) {
|
||||
filtered = filtered.filter((issue) => issue.reportDate >= filter.startDate!);
|
||||
}
|
||||
if (filter?.endDate) {
|
||||
filtered = filtered.filter((issue) => issue.reportDate <= filter.endDate!);
|
||||
}
|
||||
|
||||
// 정렬
|
||||
if (filter?.sortBy) {
|
||||
switch (filter.sortBy) {
|
||||
case 'latest':
|
||||
filtered.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
||||
break;
|
||||
case 'oldest':
|
||||
filtered.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
|
||||
break;
|
||||
case 'reportDate':
|
||||
filtered.sort((a, b) => new Date(b.reportDate).getTime() - new Date(a.reportDate).getTime());
|
||||
break;
|
||||
case 'priorityHigh':
|
||||
const priorityOrder: Record<string, number> = { urgent: 0, normal: 1 };
|
||||
filtered.sort((a, b) => priorityOrder[a.priority] - priorityOrder[b.priority]);
|
||||
break;
|
||||
case 'priorityLow':
|
||||
const priorityOrderLow: Record<string, number> = { urgent: 1, normal: 0 };
|
||||
filtered.sort((a, b) => priorityOrderLow[a.priority] - priorityOrderLow[b.priority]);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const page = filter?.page ?? 1;
|
||||
const size = filter?.size ?? 20;
|
||||
const start = (page - 1) * size;
|
||||
const paginatedItems = filtered.slice(start, start + size);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
items: paginatedItems,
|
||||
total: filtered.length,
|
||||
page,
|
||||
size,
|
||||
totalPages: Math.ceil(filtered.length / size),
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('getIssueList error:', error);
|
||||
return { success: false, error: '이슈 목록 조회에 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// 이슈 통계 조회
|
||||
export async function getIssueStats(): Promise<{
|
||||
success: boolean;
|
||||
data?: IssueStats;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
const received = mockIssues.filter((i) => i.status === 'received').length;
|
||||
const inProgress = mockIssues.filter((i) => i.status === 'in_progress').length;
|
||||
const resolved = mockIssues.filter((i) => i.status === 'resolved').length;
|
||||
const unresolved = mockIssues.filter((i) => i.status === 'unresolved').length;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
received,
|
||||
inProgress,
|
||||
resolved,
|
||||
unresolved,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('getIssueStats error:', error);
|
||||
return { success: false, error: '이슈 통계 조회에 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// 이슈 상세 조회
|
||||
export async function getIssue(
|
||||
id: string
|
||||
): Promise<{ success: boolean; data?: Issue; error?: string }> {
|
||||
try {
|
||||
const issue = mockIssues.find((i) => i.id === id);
|
||||
|
||||
if (!issue) {
|
||||
return { success: false, error: '이슈를 찾을 수 없습니다.' };
|
||||
}
|
||||
|
||||
return { success: true, data: issue };
|
||||
} catch (error) {
|
||||
console.error('getIssue error:', error);
|
||||
return { success: false, error: '이슈 조회에 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// 이슈 수정
|
||||
export async function updateIssue(
|
||||
id: string,
|
||||
data: Partial<Issue>
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
console.log('Update issue:', id, data);
|
||||
// 실제 구현에서는 DB 업데이트
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('updateIssue error:', error);
|
||||
return { success: false, error: '이슈 수정에 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// 이슈 생성
|
||||
export async function createIssue(
|
||||
data: Omit<Issue, 'id' | 'createdAt' | 'updatedAt'>
|
||||
): Promise<{ success: boolean; data?: Issue; error?: string }> {
|
||||
try {
|
||||
console.log('Create issue:', data);
|
||||
const newIssue: Issue = {
|
||||
...data,
|
||||
id: `new-${Date.now()}`,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
return { success: true, data: newIssue };
|
||||
} catch (error) {
|
||||
console.error('createIssue error:', error);
|
||||
return { success: false, error: '이슈 등록에 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// 이슈 철회 (단일)
|
||||
export async function withdrawIssue(
|
||||
id: string
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
console.log('Withdraw issue:', id);
|
||||
// 실제 구현에서는 DB 상태 업데이트 (삭제가 아닌 철회 상태로 변경)
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('withdrawIssue error:', error);
|
||||
return { success: false, error: '이슈 철회에 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// 이슈 철회 (다중)
|
||||
export async function withdrawIssues(
|
||||
ids: string[]
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
console.log('Withdraw issues:', ids);
|
||||
// 실제 구현에서는 DB 상태 일괄 업데이트
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('withdrawIssues error:', error);
|
||||
return { success: false, error: '이슈 일괄 철회에 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export { default as IssueManagementListClient } from './IssueManagementListClient';
|
||||
export { default as IssueDetailForm } from './IssueDetailForm';
|
||||
export * from './types';
|
||||
export * from './actions';
|
||||
237
src/components/business/construction/issue-management/types.ts
Normal file
237
src/components/business/construction/issue-management/types.ts
Normal file
@@ -0,0 +1,237 @@
|
||||
/**
|
||||
* 이슈관리 타입 정의
|
||||
*/
|
||||
|
||||
// 이슈 상태
|
||||
export type IssueStatus = 'received' | 'in_progress' | 'resolved' | 'unresolved';
|
||||
|
||||
// 이슈 중요도 (긴급, 일반)
|
||||
export type IssuePriority = 'urgent' | 'normal';
|
||||
|
||||
// 이슈 구분 (자재, 도면, 공정, 안전, 기타)
|
||||
export type IssueCategory = 'material' | 'drawing' | 'process' | 'safety' | 'etc';
|
||||
|
||||
// 이슈 이미지
|
||||
export interface IssueImage {
|
||||
id: string;
|
||||
url: string;
|
||||
fileName: string;
|
||||
uploadedAt: string;
|
||||
}
|
||||
|
||||
// 이슈 데이터
|
||||
export interface Issue {
|
||||
id: string;
|
||||
issueNumber: string; // 이슈번호
|
||||
constructionNumber: string; // 시공번호
|
||||
partnerName: string; // 거래처
|
||||
siteName: string; // 현장
|
||||
constructionPM?: string; // 공사PM (자동)
|
||||
constructionManagers?: string; // 공사담당자 (자동, 다중)
|
||||
category: IssueCategory; // 구분
|
||||
title: string; // 제목
|
||||
content?: string; // 내용
|
||||
reporter: string; // 보고자
|
||||
reportDate: string; // 이슈보고일
|
||||
resolvedDate: string | null; // 이슈해결일
|
||||
assignee: string; // 담당자
|
||||
priority: IssuePriority; // 중요도
|
||||
status: IssueStatus; // 상태
|
||||
images?: IssueImage[]; // 사진
|
||||
description?: string; // 설명 (레거시)
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
// 이슈 폼 데이터
|
||||
export interface IssueFormData {
|
||||
issueNumber: string;
|
||||
constructionNumber: string;
|
||||
partnerName: string;
|
||||
siteName: string;
|
||||
constructionPM: string;
|
||||
constructionManagers: string;
|
||||
reporter: string;
|
||||
assignee: string;
|
||||
reportDate: string;
|
||||
resolvedDate: string;
|
||||
status: IssueStatus;
|
||||
category: IssueCategory;
|
||||
priority: IssuePriority;
|
||||
title: string;
|
||||
content: string;
|
||||
images: IssueImage[];
|
||||
}
|
||||
|
||||
// 이슈 통계
|
||||
export interface IssueStats {
|
||||
received: number; // 접수
|
||||
inProgress: number; // 처리중
|
||||
resolved: number; // 해결완료
|
||||
unresolved: number; // 미해결
|
||||
}
|
||||
|
||||
// 이슈 필터
|
||||
export interface IssueFilter {
|
||||
partners?: string[]; // 거래처 (다중선택)
|
||||
sites?: string[]; // 현장 (다중선택)
|
||||
categories?: string[]; // 구분 (다중선택)
|
||||
reporters?: string[]; // 보고자 (다중선택)
|
||||
assignees?: string[]; // 담당자 (다중선택)
|
||||
priority?: string; // 중요도 (단일선택)
|
||||
status?: string; // 상태 (단일선택)
|
||||
sortBy?: string; // 정렬
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
page?: number;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
// API 응답
|
||||
export interface IssueListResponse {
|
||||
items: Issue[];
|
||||
total: number;
|
||||
page: number;
|
||||
size: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
// 상태 옵션
|
||||
export const ISSUE_STATUS_OPTIONS = [
|
||||
{ value: 'all', label: '전체' },
|
||||
{ value: 'received', label: '접수' },
|
||||
{ value: 'in_progress', label: '처리중' },
|
||||
{ value: 'resolved', label: '해결완료' },
|
||||
{ value: 'unresolved', label: '미해결' },
|
||||
];
|
||||
|
||||
// 상태 라벨
|
||||
export const ISSUE_STATUS_LABELS: Record<IssueStatus, string> = {
|
||||
received: '접수',
|
||||
in_progress: '처리중',
|
||||
resolved: '해결완료',
|
||||
unresolved: '미해결',
|
||||
};
|
||||
|
||||
// 상태 스타일
|
||||
export const ISSUE_STATUS_STYLES: Record<IssueStatus, string> = {
|
||||
received: 'bg-blue-100 text-blue-700',
|
||||
in_progress: 'bg-yellow-100 text-yellow-700',
|
||||
resolved: 'bg-green-100 text-green-700',
|
||||
unresolved: 'bg-red-100 text-red-700',
|
||||
};
|
||||
|
||||
// 중요도 옵션 (긴급, 일반)
|
||||
export const ISSUE_PRIORITY_OPTIONS = [
|
||||
{ value: 'all', label: '전체' },
|
||||
{ value: 'urgent', label: '긴급' },
|
||||
{ value: 'normal', label: '일반' },
|
||||
];
|
||||
|
||||
// 중요도 라벨
|
||||
export const ISSUE_PRIORITY_LABELS: Record<IssuePriority, string> = {
|
||||
urgent: '긴급',
|
||||
normal: '일반',
|
||||
};
|
||||
|
||||
// 중요도 스타일
|
||||
export const ISSUE_PRIORITY_STYLES: Record<IssuePriority, string> = {
|
||||
urgent: 'bg-red-100 text-red-700',
|
||||
normal: 'bg-gray-100 text-gray-700',
|
||||
};
|
||||
|
||||
// 구분 옵션 (자재, 도면, 공정, 안전, 기타)
|
||||
export const ISSUE_CATEGORY_OPTIONS = [
|
||||
{ value: 'all', label: '전체' },
|
||||
{ value: 'material', label: '자재' },
|
||||
{ value: 'drawing', label: '도면' },
|
||||
{ value: 'process', label: '공정' },
|
||||
{ value: 'safety', label: '안전' },
|
||||
{ value: 'etc', label: '기타' },
|
||||
];
|
||||
|
||||
// 구분 라벨
|
||||
export const ISSUE_CATEGORY_LABELS: Record<IssueCategory, string> = {
|
||||
material: '자재',
|
||||
drawing: '도면',
|
||||
process: '공정',
|
||||
safety: '안전',
|
||||
etc: '기타',
|
||||
};
|
||||
|
||||
// 정렬 옵션
|
||||
export const ISSUE_SORT_OPTIONS = [
|
||||
{ value: 'latest', label: '최신순' },
|
||||
{ value: 'oldest', label: '오래된순' },
|
||||
{ value: 'reportDate', label: '보고일순' },
|
||||
{ value: 'priorityHigh', label: '중요도 높은순' },
|
||||
{ value: 'priorityLow', label: '중요도 낮은순' },
|
||||
];
|
||||
|
||||
// Mock 거래처 데이터
|
||||
export const MOCK_ISSUE_PARTNERS = [
|
||||
{ value: 'partner1', label: '대한건설' },
|
||||
{ value: 'partner2', label: '삼성시공' },
|
||||
{ value: 'partner3', label: 'LG건설' },
|
||||
{ value: 'partner4', label: '현대건설' },
|
||||
{ value: 'partner5', label: 'SK건설' },
|
||||
];
|
||||
|
||||
// Mock 현장 데이터
|
||||
export const MOCK_ISSUE_SITES = [
|
||||
{ value: 'site1', label: '서울 강남 현장' },
|
||||
{ value: 'site2', label: '부산 해운대 현장' },
|
||||
{ value: 'site3', label: '대전 유성 현장' },
|
||||
{ value: 'site4', label: '인천 송도 현장' },
|
||||
{ value: 'site5', label: '광주 북구 현장' },
|
||||
];
|
||||
|
||||
// Mock 보고자 데이터
|
||||
export const MOCK_ISSUE_REPORTERS = [
|
||||
{ value: 'reporter1', label: '홍길동' },
|
||||
{ value: 'reporter2', label: '김철수' },
|
||||
{ value: 'reporter3', label: '이영희' },
|
||||
{ value: 'reporter4', label: '박민수' },
|
||||
{ value: 'reporter5', label: '최대리' },
|
||||
];
|
||||
|
||||
// Mock 담당자 데이터
|
||||
export const MOCK_ISSUE_ASSIGNEES = [
|
||||
{ value: 'assignee1', label: '김과장' },
|
||||
{ value: 'assignee2', label: '이부장' },
|
||||
{ value: 'assignee3', label: '박대리' },
|
||||
{ value: 'assignee4', label: '정차장' },
|
||||
{ value: 'assignee5', label: '송이사' },
|
||||
];
|
||||
|
||||
// Mock 시공번호 데이터 (상세 폼용)
|
||||
export const MOCK_CONSTRUCTION_NUMBERS = [
|
||||
{ value: 'CON-001', label: 'CON-001', partnerName: '대한건설', siteName: '서울 강남 현장', pm: '홍길동', managers: '홍길동, 김철수, 이영희' },
|
||||
{ value: 'CON-002', label: 'CON-002', partnerName: '삼성시공', siteName: '부산 해운대 현장', pm: '김철수', managers: '김철수, 박민수' },
|
||||
{ value: 'CON-003', label: 'CON-003', partnerName: 'LG건설', siteName: '대전 유성 현장', pm: '이영희', managers: '이영희, 최대리' },
|
||||
{ value: 'CON-004', label: 'CON-004', partnerName: '현대건설', siteName: '인천 송도 현장', pm: '박민수', managers: '박민수, 홍길동' },
|
||||
{ value: 'CON-005', label: 'CON-005', partnerName: 'SK건설', siteName: '광주 북구 현장', pm: '최대리', managers: '최대리, 김철수, 이영희' },
|
||||
];
|
||||
|
||||
// 폼용 상태 옵션 (전체 제외)
|
||||
export const ISSUE_STATUS_FORM_OPTIONS = [
|
||||
{ value: 'received', label: '접수' },
|
||||
{ value: 'in_progress', label: '처리중' },
|
||||
{ value: 'resolved', label: '해결완료' },
|
||||
{ value: 'unresolved', label: '미해결' },
|
||||
];
|
||||
|
||||
// 폼용 중요도 옵션 (전체 제외)
|
||||
export const ISSUE_PRIORITY_FORM_OPTIONS = [
|
||||
{ value: 'urgent', label: '긴급' },
|
||||
{ value: 'normal', label: '일반' },
|
||||
];
|
||||
|
||||
// 폼용 구분 옵션 (전체 제외)
|
||||
export const ISSUE_CATEGORY_FORM_OPTIONS = [
|
||||
{ value: 'material', label: '자재' },
|
||||
{ value: 'drawing', label: '도면' },
|
||||
{ value: 'process', label: '공정' },
|
||||
{ value: 'safety', label: '안전' },
|
||||
{ value: 'etc', label: '기타' },
|
||||
];
|
||||
@@ -512,59 +512,58 @@ export default function ItemDetailClient({
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 발주 항목 */}
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle className="text-base">발주 항목</CardTitle>
|
||||
{!isReadOnly && (
|
||||
<Button variant="outline" size="sm" onClick={handleAddOrderItem}>
|
||||
<Plus className="mr-1 h-4 w-4" />
|
||||
추가
|
||||
</Button>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{formData.orderItems.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
발주 항목이 없습니다.
|
||||
{!isReadOnly && ' 추가 버튼을 클릭하여 항목을 추가하세요.'}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-[1fr_1fr_40px] gap-2 text-sm font-medium text-muted-foreground">
|
||||
<div>항목명</div>
|
||||
<div>구분 정보</div>
|
||||
<div></div>
|
||||
{/* 발주 항목 구분정보 */}
|
||||
{/* 수정 모드: 항상 표시 (추가/삭제 가능) */}
|
||||
{/* 상세 모드: 데이터가 있을 때만 표시 (읽기 전용) */}
|
||||
{(!isReadOnly || formData.orderItems.length > 0) && (
|
||||
<div className="pt-4">
|
||||
{/* 헤더 */}
|
||||
<div className="grid grid-cols-[1fr_1fr_auto] gap-4 items-center mb-4">
|
||||
<div className="text-base font-semibold">발주 항목</div>
|
||||
<div className="text-base font-semibold">구분 정보</div>
|
||||
{!isReadOnly && (
|
||||
<Button size="sm" onClick={handleAddOrderItem}>
|
||||
추가
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{formData.orderItems.map((item) => (
|
||||
<div key={item.id} className="grid grid-cols-[1fr_1fr_40px] gap-2 items-center">
|
||||
<Input
|
||||
value={item.label}
|
||||
onChange={(e) => handleOrderItemChange(item.id, 'label', e.target.value)}
|
||||
placeholder="예: 무게"
|
||||
disabled={isReadOnly}
|
||||
/>
|
||||
<Input
|
||||
value={item.value}
|
||||
onChange={(e) => handleOrderItemChange(item.id, 'value', e.target.value)}
|
||||
placeholder="예: 400KG"
|
||||
disabled={isReadOnly}
|
||||
/>
|
||||
{!isReadOnly && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-9 w-9"
|
||||
onClick={() => handleRemoveOrderItem(item.id)}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* 항목 리스트 */}
|
||||
{formData.orderItems.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground border rounded-lg">
|
||||
발주 항목이 없습니다. 추가 버튼을 클릭하여 항목을 추가하세요.
|
||||
</div>
|
||||
))}
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{formData.orderItems.map((item) => (
|
||||
<div key={item.id} className={`grid ${isReadOnly ? 'grid-cols-2' : 'grid-cols-[1fr_1fr_auto]'} gap-4 items-center`}>
|
||||
<Input
|
||||
value={item.label}
|
||||
onChange={(e) => handleOrderItemChange(item.id, 'label', e.target.value)}
|
||||
placeholder="예: 무게"
|
||||
disabled={isReadOnly}
|
||||
/>
|
||||
<Input
|
||||
value={item.value}
|
||||
onChange={(e) => handleOrderItemChange(item.id, 'value', e.target.value)}
|
||||
placeholder="예: 400KG"
|
||||
disabled={isReadOnly}
|
||||
/>
|
||||
{!isReadOnly && (
|
||||
<Button
|
||||
variant="default"
|
||||
size="icon"
|
||||
className="h-10 w-10 bg-black hover:bg-black/80"
|
||||
onClick={() => handleRemoveOrderItem(item.id)}
|
||||
>
|
||||
<X className="h-4 w-4 text-white" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
@@ -8,14 +8,7 @@ import { Button } from '@/components/ui/button';
|
||||
import { TableCell, TableRow } from '@/components/ui/table';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { IntegratedListTemplateV2, TableColumn } from '@/components/templates/IntegratedListTemplateV2';
|
||||
import { IntegratedListTemplateV2, TableColumn, FilterFieldConfig, FilterValues } from '@/components/templates/IntegratedListTemplateV2';
|
||||
import { MobileCard } from '@/components/molecules/MobileCard';
|
||||
import { DateRangeSelector } from '@/components/molecules/DateRangeSelector';
|
||||
import { toast } from 'sonner';
|
||||
@@ -427,130 +420,112 @@ export default function ItemManagementClient({
|
||||
/>
|
||||
);
|
||||
|
||||
// 테이블 헤더 액션 (6개 필터)
|
||||
const tableHeaderActions = (
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{/* 총 건수 */}
|
||||
<span className="text-sm text-muted-foreground whitespace-nowrap">
|
||||
총 {sortedItems.length}건
|
||||
</span>
|
||||
// ===== filterConfig 기반 통합 필터 시스템 =====
|
||||
const filterConfig: FilterFieldConfig[] = useMemo(() => [
|
||||
{
|
||||
key: 'itemType',
|
||||
label: '품목유형',
|
||||
type: 'single',
|
||||
options: ITEM_TYPE_OPTIONS.filter(o => o.value !== 'all').map(o => ({
|
||||
value: o.value,
|
||||
label: o.label,
|
||||
})),
|
||||
allOptionLabel: '전체',
|
||||
},
|
||||
{
|
||||
key: 'category',
|
||||
label: '카테고리',
|
||||
type: 'single',
|
||||
options: categoryOptions.map(c => ({
|
||||
value: c.id,
|
||||
label: c.name,
|
||||
})),
|
||||
allOptionLabel: '전체',
|
||||
},
|
||||
{
|
||||
key: 'specification',
|
||||
label: '규격',
|
||||
type: 'single',
|
||||
options: SPECIFICATION_OPTIONS.filter(o => o.value !== 'all').map(o => ({
|
||||
value: o.value,
|
||||
label: o.label,
|
||||
})),
|
||||
allOptionLabel: '전체',
|
||||
},
|
||||
{
|
||||
key: 'orderType',
|
||||
label: '구분',
|
||||
type: 'single',
|
||||
options: ORDER_TYPE_OPTIONS.filter(o => o.value !== 'all').map(o => ({
|
||||
value: o.value,
|
||||
label: o.label,
|
||||
})),
|
||||
allOptionLabel: '전체',
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
label: '상태',
|
||||
type: 'single',
|
||||
options: STATUS_OPTIONS.filter(o => o.value !== 'all').map(o => ({
|
||||
value: o.value,
|
||||
label: o.label,
|
||||
})),
|
||||
allOptionLabel: '전체',
|
||||
},
|
||||
{
|
||||
key: 'sortBy',
|
||||
label: '정렬',
|
||||
type: 'single',
|
||||
options: SORT_OPTIONS.map(o => ({
|
||||
value: o.value,
|
||||
label: o.label,
|
||||
})),
|
||||
allOptionLabel: '최신순',
|
||||
},
|
||||
], [categoryOptions]);
|
||||
|
||||
{/* 품목유형 필터 */}
|
||||
<Select
|
||||
value={itemTypeFilter}
|
||||
onValueChange={(v) => {
|
||||
setItemTypeFilter(v as ItemType | 'all');
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-[90px]">
|
||||
<SelectValue placeholder="품목유형" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ITEM_TYPE_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
const filterValues: FilterValues = useMemo(() => ({
|
||||
itemType: itemTypeFilter,
|
||||
category: categoryFilter,
|
||||
specification: specificationFilter,
|
||||
orderType: orderTypeFilter,
|
||||
status: statusFilter,
|
||||
sortBy: sortBy,
|
||||
}), [itemTypeFilter, categoryFilter, specificationFilter, orderTypeFilter, statusFilter, sortBy]);
|
||||
|
||||
{/* 카테고리 필터 */}
|
||||
<Select
|
||||
value={categoryFilter}
|
||||
onValueChange={(v) => {
|
||||
setCategoryFilter(v);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-[100px]">
|
||||
<SelectValue placeholder="카테고리" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">전체</SelectItem>
|
||||
{categoryOptions.map((category) => (
|
||||
<SelectItem key={category.id} value={category.id}>
|
||||
{category.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
const handleFilterChange = useCallback((key: string, value: string | string[]) => {
|
||||
switch (key) {
|
||||
case 'itemType':
|
||||
setItemTypeFilter(value as ItemType | 'all');
|
||||
break;
|
||||
case 'category':
|
||||
setCategoryFilter(value as string);
|
||||
break;
|
||||
case 'specification':
|
||||
setSpecificationFilter(value as Specification | 'all');
|
||||
break;
|
||||
case 'orderType':
|
||||
setOrderTypeFilter(value as OrderType | 'all');
|
||||
break;
|
||||
case 'status':
|
||||
setStatusFilter(value as ItemStatus | 'all');
|
||||
break;
|
||||
case 'sortBy':
|
||||
setSortBy(value as 'latest' | 'oldest');
|
||||
break;
|
||||
}
|
||||
setCurrentPage(1);
|
||||
}, []);
|
||||
|
||||
{/* 규격 필터 */}
|
||||
<Select
|
||||
value={specificationFilter}
|
||||
onValueChange={(v) => {
|
||||
setSpecificationFilter(v as Specification | 'all');
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-[80px]">
|
||||
<SelectValue placeholder="규격" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SPECIFICATION_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* 구분 필터 */}
|
||||
<Select
|
||||
value={orderTypeFilter}
|
||||
onValueChange={(v) => {
|
||||
setOrderTypeFilter(v as OrderType | 'all');
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-[100px]">
|
||||
<SelectValue placeholder="구분" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ORDER_TYPE_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* 상태 필터 */}
|
||||
<Select
|
||||
value={statusFilter}
|
||||
onValueChange={(v) => {
|
||||
setStatusFilter(v as ItemStatus | 'all');
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-[80px]">
|
||||
<SelectValue placeholder="상태" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{STATUS_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* 정렬 */}
|
||||
<Select value={sortBy} onValueChange={(v) => setSortBy(v as 'latest' | 'oldest')}>
|
||||
<SelectTrigger className="w-[90px]">
|
||||
<SelectValue placeholder="정렬" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SORT_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
const handleFilterReset = useCallback(() => {
|
||||
setItemTypeFilter('all');
|
||||
setCategoryFilter('all');
|
||||
setSpecificationFilter('all');
|
||||
setOrderTypeFilter('all');
|
||||
setStatusFilter('all');
|
||||
setSortBy('latest');
|
||||
setCurrentPage(1);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -573,7 +548,11 @@ export default function ItemManagementClient({
|
||||
iconColor: 'text-green-500',
|
||||
},
|
||||
]}
|
||||
tableHeaderActions={tableHeaderActions}
|
||||
filterConfig={filterConfig}
|
||||
filterValues={filterValues}
|
||||
onFilterChange={handleFilterChange}
|
||||
onFilterReset={handleFilterReset}
|
||||
filterTitle="품목 필터"
|
||||
searchValue={searchValue}
|
||||
onSearchChange={handleSearchChange}
|
||||
searchPlaceholder="품목명, 품목번호, 카테고리 검색"
|
||||
|
||||
@@ -62,6 +62,10 @@ export default function LaborDetailClient({
|
||||
const [formData, setFormData] = useState<LaborFormData>(initialFormData);
|
||||
const [originalData, setOriginalData] = useState<Labor | null>(null);
|
||||
|
||||
// 소수점 입력을 위한 문자열 상태 (입력 중인 값 유지)
|
||||
const [minMInput, setMinMInput] = useState<string>('');
|
||||
const [maxMInput, setMaxMInput] = useState<string>('');
|
||||
|
||||
// 상태
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
@@ -84,6 +88,9 @@ export default function LaborDetailClient({
|
||||
laborPrice: result.data.laborPrice,
|
||||
status: result.data.status,
|
||||
});
|
||||
// 소수점 입력용 문자열 상태 초기화
|
||||
setMinMInput(result.data.minM === 0 ? '' : result.data.minM.toString());
|
||||
setMaxMInput(result.data.maxM === 0 ? '' : result.data.maxM.toString());
|
||||
} else {
|
||||
toast.error(result.error || '노임 정보를 불러오는데 실패했습니다.');
|
||||
router.push('/ko/construction/order/base-info/labor');
|
||||
@@ -107,19 +114,62 @@ export default function LaborDetailClient({
|
||||
[]
|
||||
);
|
||||
|
||||
// 숫자 입력 (소수점 둘째자리까지)
|
||||
const handleNumberChange = useCallback(
|
||||
(field: 'minM' | 'maxM' | 'laborPrice', value: string) => {
|
||||
// 최소 M / 최대 M 입력 핸들러 (소수점 둘째자리까지)
|
||||
const handleMinMChange = useCallback(
|
||||
(value: string) => {
|
||||
// 빈 값 허용
|
||||
if (value === '') {
|
||||
handleFieldChange(field, field === 'laborPrice' ? null : 0);
|
||||
setMinMInput('');
|
||||
handleFieldChange('minM', 0);
|
||||
return;
|
||||
}
|
||||
// 소수점 둘째자리까지 허용
|
||||
// 소수점 둘째자리까지 허용하는 정규식
|
||||
const regex = /^\d*\.?\d{0,2}$/;
|
||||
if (regex.test(value)) {
|
||||
setMinMInput(value);
|
||||
const numValue = parseFloat(value);
|
||||
if (!isNaN(numValue)) {
|
||||
handleFieldChange(field, numValue);
|
||||
handleFieldChange('minM', numValue);
|
||||
}
|
||||
}
|
||||
},
|
||||
[handleFieldChange]
|
||||
);
|
||||
|
||||
const handleMaxMChange = useCallback(
|
||||
(value: string) => {
|
||||
// 빈 값 허용
|
||||
if (value === '') {
|
||||
setMaxMInput('');
|
||||
handleFieldChange('maxM', 0);
|
||||
return;
|
||||
}
|
||||
// 소수점 둘째자리까지 허용하는 정규식
|
||||
const regex = /^\d*\.?\d{0,2}$/;
|
||||
if (regex.test(value)) {
|
||||
setMaxMInput(value);
|
||||
const numValue = parseFloat(value);
|
||||
if (!isNaN(numValue)) {
|
||||
handleFieldChange('maxM', numValue);
|
||||
}
|
||||
}
|
||||
},
|
||||
[handleFieldChange]
|
||||
);
|
||||
|
||||
// 노임단가 입력 핸들러 (정수만)
|
||||
const handleLaborPriceChange = useCallback(
|
||||
(value: string) => {
|
||||
if (value === '') {
|
||||
handleFieldChange('laborPrice', null);
|
||||
return;
|
||||
}
|
||||
// 정수만 허용
|
||||
const regex = /^\d*$/;
|
||||
if (regex.test(value)) {
|
||||
const numValue = parseInt(value, 10);
|
||||
if (!isNaN(numValue)) {
|
||||
handleFieldChange('laborPrice', numValue);
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -213,6 +263,9 @@ export default function LaborDetailClient({
|
||||
laborPrice: originalData.laborPrice,
|
||||
status: originalData.status,
|
||||
});
|
||||
// 소수점 입력용 문자열 상태도 복원
|
||||
setMinMInput(originalData.minM === 0 ? '' : originalData.minM.toString());
|
||||
setMaxMInput(originalData.maxM === 0 ? '' : originalData.maxM.toString());
|
||||
}
|
||||
router.replace(`/ko/construction/order/base-info/labor/${laborId}`);
|
||||
}
|
||||
@@ -339,8 +392,8 @@ export default function LaborDetailClient({
|
||||
id="minM"
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
value={formData.minM === 0 ? '' : formData.minM.toString()}
|
||||
onChange={(e) => handleNumberChange('minM', e.target.value)}
|
||||
value={minMInput}
|
||||
onChange={(e) => handleMinMChange(e.target.value)}
|
||||
placeholder="0.00"
|
||||
disabled={isReadOnly}
|
||||
/>
|
||||
@@ -351,8 +404,8 @@ export default function LaborDetailClient({
|
||||
id="maxM"
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
value={formData.maxM === 0 ? '' : formData.maxM.toString()}
|
||||
onChange={(e) => handleNumberChange('maxM', e.target.value)}
|
||||
value={maxMInput}
|
||||
onChange={(e) => handleMaxMChange(e.target.value)}
|
||||
placeholder="0.00"
|
||||
disabled={isReadOnly}
|
||||
/>
|
||||
@@ -366,9 +419,9 @@ export default function LaborDetailClient({
|
||||
<Input
|
||||
id="laborPrice"
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
inputMode="numeric"
|
||||
value={formData.laborPrice === null ? '' : formData.laborPrice.toString()}
|
||||
onChange={(e) => handleNumberChange('laborPrice', e.target.value)}
|
||||
onChange={(e) => handleLaborPriceChange(e.target.value)}
|
||||
placeholder="0"
|
||||
disabled={isReadOnly}
|
||||
/>
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { IntegratedListTemplateV2, TableColumn } from '@/components/templates/IntegratedListTemplateV2';
|
||||
import { IntegratedListTemplateV2, TableColumn, FilterFieldConfig, FilterValues } from '@/components/templates/IntegratedListTemplateV2';
|
||||
import { MobileCard } from '@/components/molecules/MobileCard';
|
||||
import { DateRangeSelector } from '@/components/molecules/DateRangeSelector';
|
||||
import { toast } from 'sonner';
|
||||
@@ -369,6 +369,66 @@ export default function LaborManagementClient({
|
||||
[handleRowClick]
|
||||
);
|
||||
|
||||
// ===== filterConfig 기반 통합 필터 시스템 =====
|
||||
const filterConfig: FilterFieldConfig[] = useMemo(() => [
|
||||
{
|
||||
key: 'category',
|
||||
label: '구분',
|
||||
type: 'single',
|
||||
options: CATEGORY_OPTIONS.filter(o => o.value !== 'all').map(o => ({
|
||||
value: o.value,
|
||||
label: o.label,
|
||||
})),
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
label: '상태',
|
||||
type: 'single',
|
||||
options: STATUS_OPTIONS.filter(o => o.value !== 'all').map(o => ({
|
||||
value: o.value,
|
||||
label: o.label,
|
||||
})),
|
||||
},
|
||||
{
|
||||
key: 'sortBy',
|
||||
label: '정렬',
|
||||
type: 'single',
|
||||
options: SORT_OPTIONS.map(o => ({
|
||||
value: o.value,
|
||||
label: o.label,
|
||||
})),
|
||||
allOptionLabel: '최신순',
|
||||
},
|
||||
], []);
|
||||
|
||||
const filterValues: FilterValues = useMemo(() => ({
|
||||
category: categoryFilter,
|
||||
status: statusFilter,
|
||||
sortBy: sortBy,
|
||||
}), [categoryFilter, statusFilter, sortBy]);
|
||||
|
||||
const handleFilterChange = useCallback((key: string, value: string | string[]) => {
|
||||
switch (key) {
|
||||
case 'category':
|
||||
setCategoryFilter(value as LaborCategory | 'all');
|
||||
break;
|
||||
case 'status':
|
||||
setStatusFilter(value as LaborStatus | 'all');
|
||||
break;
|
||||
case 'sortBy':
|
||||
setSortBy(value as SortOrder);
|
||||
break;
|
||||
}
|
||||
setCurrentPage(1);
|
||||
}, []);
|
||||
|
||||
const handleFilterReset = useCallback(() => {
|
||||
setCategoryFilter('all');
|
||||
setStatusFilter('all');
|
||||
setSortBy('최신순');
|
||||
setCurrentPage(1);
|
||||
}, []);
|
||||
|
||||
// 헤더 액션 (날짜선택 + 등록 버튼)
|
||||
const headerActions = (
|
||||
<DateRangeSelector
|
||||
@@ -471,6 +531,11 @@ export default function LaborManagementClient({
|
||||
},
|
||||
]}
|
||||
tableHeaderActions={tableHeaderActions}
|
||||
filterConfig={filterConfig}
|
||||
filterValues={filterValues}
|
||||
onFilterChange={handleFilterChange}
|
||||
onFilterReset={handleFilterReset}
|
||||
filterTitle="노임 필터"
|
||||
searchValue={searchValue}
|
||||
onSearchChange={handleSearchChange}
|
||||
searchPlaceholder="노임번호, 구분 검색"
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
// 구분 옵션
|
||||
export const CATEGORY_OPTIONS = [
|
||||
{ value: 'all', label: '전체' },
|
||||
{ value: '작업반장', label: '작업반장' },
|
||||
{ value: '작업자', label: '작업자' },
|
||||
{ value: '가로', label: '가로' },
|
||||
{ value: '세로할증', label: '세로할증' },
|
||||
] as const;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// 노임관리 타입 정의
|
||||
|
||||
// 구분 타입
|
||||
export type LaborCategory = '가로' | '세로할증';
|
||||
export type LaborCategory = '작업반장' | '작업자' | '가로' | '세로할증';
|
||||
|
||||
// 상태 타입
|
||||
export type LaborStatus = '사용' | '중지';
|
||||
|
||||
@@ -0,0 +1,773 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Wrench, List, Plus, Trash2, FileText, Upload, X } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { MultiSelectCombobox } from '@/components/ui/multi-select-combobox';
|
||||
import { PageLayout } from '@/components/organisms/PageLayout';
|
||||
import { PageHeader } from '@/components/organisms/PageHeader';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
getConstructionManagementDetail,
|
||||
updateConstructionManagementDetail,
|
||||
completeConstruction,
|
||||
} from './actions';
|
||||
import { getOrderDetailFull } from '../order-management/actions';
|
||||
import { OrderDocumentModal } from '../order-management/modals/OrderDocumentModal';
|
||||
import type {
|
||||
ConstructionManagementDetail,
|
||||
ConstructionDetailFormData,
|
||||
WorkerInfo,
|
||||
WorkProgressInfo,
|
||||
PhotoInfo,
|
||||
} from './types';
|
||||
import type { OrderDetail } from '../order-management/types';
|
||||
import {
|
||||
MOCK_EMPLOYEES,
|
||||
MOCK_CM_WORK_TEAM_LEADERS,
|
||||
CONSTRUCTION_MANAGEMENT_STATUS_LABELS,
|
||||
CONSTRUCTION_MANAGEMENT_STATUS_STYLES,
|
||||
} from './types';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
|
||||
interface ConstructionDetailClientProps {
|
||||
id: string;
|
||||
mode: 'view' | 'edit';
|
||||
}
|
||||
|
||||
// 날짜 포맷팅
|
||||
function formatDate(dateStr: string | null): string {
|
||||
if (!dateStr) return '-';
|
||||
const date = new Date(dateStr);
|
||||
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
export default function ConstructionDetailClient({ id, mode }: ConstructionDetailClientProps) {
|
||||
const router = useRouter();
|
||||
|
||||
// 모드 플래그
|
||||
const isViewMode = mode === 'view';
|
||||
const isEditMode = mode === 'edit';
|
||||
|
||||
// 데이터 상태
|
||||
const [detail, setDetail] = useState<ConstructionManagementDetail | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
// 폼 데이터 상태
|
||||
const [formData, setFormData] = useState<ConstructionDetailFormData>({
|
||||
workTeamLeader: '',
|
||||
workerInfoList: [],
|
||||
workProgressList: [],
|
||||
workLogContent: '',
|
||||
photos: [],
|
||||
isIssueReported: false,
|
||||
});
|
||||
|
||||
// 발주서 모달 상태
|
||||
const [showOrderModal, setShowOrderModal] = useState(false);
|
||||
const [orderData, setOrderData] = useState<OrderDetail | null>(null);
|
||||
|
||||
// 시공 완료 다이얼로그 상태
|
||||
const [showCompleteDialog, setShowCompleteDialog] = useState(false);
|
||||
|
||||
// 데이터 로드
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const result = await getConstructionManagementDetail(id);
|
||||
|
||||
if (result.success && result.data) {
|
||||
setDetail(result.data);
|
||||
setFormData({
|
||||
workTeamLeader: result.data.workTeamLeader,
|
||||
workerInfoList: result.data.workerInfoList,
|
||||
workProgressList: result.data.workProgressList,
|
||||
workLogContent: result.data.workLogContent,
|
||||
photos: result.data.photos,
|
||||
isIssueReported: result.data.isIssueReported,
|
||||
});
|
||||
} else {
|
||||
toast.error(result.error || '시공 정보를 불러올 수 없습니다.');
|
||||
router.push('/ko/construction/project/construction-management');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load construction detail:', error);
|
||||
toast.error('시공 정보를 불러올 수 없습니다.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadData();
|
||||
}, [id, router]);
|
||||
|
||||
// 목록으로 돌아가기
|
||||
const handleBack = () => {
|
||||
router.push('/ko/construction/project/construction-management');
|
||||
};
|
||||
|
||||
// 수정 페이지로 이동
|
||||
const handleEdit = () => {
|
||||
router.push(`/ko/construction/project/construction-management/${id}/edit`);
|
||||
};
|
||||
|
||||
// 취소 (상세 페이지로 돌아가기)
|
||||
const handleCancel = () => {
|
||||
router.push(`/ko/construction/project/construction-management/${id}`);
|
||||
};
|
||||
|
||||
// 작업반장 변경
|
||||
const handleWorkTeamLeaderChange = (value: string) => {
|
||||
setFormData((prev) => ({ ...prev, workTeamLeader: value }));
|
||||
};
|
||||
|
||||
// 작업자 정보 추가
|
||||
const handleAddWorkerInfo = () => {
|
||||
const newWorkerInfo: WorkerInfo = {
|
||||
id: `worker-${Date.now()}`,
|
||||
workDate: new Date().toISOString().split('T')[0],
|
||||
workers: [],
|
||||
};
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
workerInfoList: [...prev.workerInfoList, newWorkerInfo],
|
||||
}));
|
||||
};
|
||||
|
||||
// 작업자 정보 삭제
|
||||
const handleDeleteWorkerInfo = (workerId: string) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
workerInfoList: prev.workerInfoList.filter((w) => w.id !== workerId),
|
||||
}));
|
||||
};
|
||||
|
||||
// 작업자 정보 변경
|
||||
const handleWorkerInfoChange = (
|
||||
workerId: string,
|
||||
field: keyof WorkerInfo,
|
||||
value: string | string[]
|
||||
) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
workerInfoList: prev.workerInfoList.map((w) =>
|
||||
w.id === workerId ? { ...w, [field]: value } : w
|
||||
),
|
||||
}));
|
||||
};
|
||||
|
||||
// 공과 정보 추가
|
||||
const handleAddWorkProgress = () => {
|
||||
const newProgress: WorkProgressInfo = {
|
||||
id: `progress-${Date.now()}`,
|
||||
scheduleDate: '',
|
||||
workName: '',
|
||||
};
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
workProgressList: [...prev.workProgressList, newProgress],
|
||||
}));
|
||||
};
|
||||
|
||||
// 공과 정보 삭제
|
||||
const handleDeleteWorkProgress = (progressId: string) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
workProgressList: prev.workProgressList.filter((p) => p.id !== progressId),
|
||||
}));
|
||||
};
|
||||
|
||||
// 공과 정보 변경
|
||||
const handleWorkProgressChange = (
|
||||
progressId: string,
|
||||
field: keyof WorkProgressInfo,
|
||||
value: string
|
||||
) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
workProgressList: prev.workProgressList.map((p) =>
|
||||
p.id === progressId ? { ...p, [field]: value } : p
|
||||
),
|
||||
}));
|
||||
};
|
||||
|
||||
// 작업일지 변경
|
||||
const handleWorkLogChange = (value: string) => {
|
||||
setFormData((prev) => ({ ...prev, workLogContent: value }));
|
||||
};
|
||||
|
||||
// 사진 업로드 (임시 - 실제로는 파일 업로드 로직 필요)
|
||||
const handlePhotoUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = e.target.files;
|
||||
if (!files) return;
|
||||
|
||||
// 임시 목업: 파일 정보를 photos에 추가
|
||||
const newPhotos: PhotoInfo[] = Array.from(files).map((file, index) => ({
|
||||
id: `photo-${Date.now()}-${index}`,
|
||||
url: URL.createObjectURL(file),
|
||||
name: file.name,
|
||||
uploadedAt: new Date().toISOString(),
|
||||
}));
|
||||
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
photos: [...prev.photos, ...newPhotos],
|
||||
}));
|
||||
};
|
||||
|
||||
// 사진 삭제
|
||||
const handleDeletePhoto = (photoId: string) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
photos: prev.photos.filter((p) => p.id !== photoId),
|
||||
}));
|
||||
};
|
||||
|
||||
// 발주서 보기
|
||||
const handleViewOrder = async () => {
|
||||
if (!detail?.orderId) return;
|
||||
|
||||
try {
|
||||
const result = await getOrderDetailFull(detail.orderId);
|
||||
if (result.success && result.data) {
|
||||
setOrderData(result.data);
|
||||
setShowOrderModal(true);
|
||||
} else {
|
||||
toast.error('발주서 정보를 불러올 수 없습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load order detail:', error);
|
||||
toast.error('발주서 정보를 불러올 수 없습니다.');
|
||||
}
|
||||
};
|
||||
|
||||
// 저장
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
const result = await updateConstructionManagementDetail(id, formData);
|
||||
if (result.success) {
|
||||
toast.success('저장되었습니다.');
|
||||
} else {
|
||||
toast.error(result.error || '저장에 실패했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to save:', error);
|
||||
toast.error('저장에 실패했습니다.');
|
||||
}
|
||||
};
|
||||
|
||||
// 시공 완료 버튼 활성화 조건: 작업일지 + 사진 모두 있어야 함
|
||||
const canComplete =
|
||||
detail?.status === 'in_progress' &&
|
||||
formData.workLogContent.trim() !== '' &&
|
||||
formData.photos.length > 0;
|
||||
|
||||
// 시공 완료 처리
|
||||
const handleComplete = async () => {
|
||||
try {
|
||||
const result = await completeConstruction(id);
|
||||
if (result.success) {
|
||||
toast.success('시공이 완료되었습니다.');
|
||||
router.push('/ko/construction/project/construction-management');
|
||||
} else {
|
||||
toast.error(result.error || '시공 완료 처리에 실패했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to complete:', error);
|
||||
toast.error('시공 완료 처리에 실패했습니다.');
|
||||
}
|
||||
};
|
||||
|
||||
// 로딩 상태
|
||||
if (isLoading) {
|
||||
return (
|
||||
<PageLayout>
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-muted-foreground">로딩 중...</div>
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
if (!detail) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 헤더 액션 - view/edit 모드에 따라 분리
|
||||
const headerActions = isViewMode ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" onClick={handleBack}>
|
||||
<List className="h-4 w-4 mr-2" />
|
||||
목록
|
||||
</Button>
|
||||
<Button onClick={handleEdit}>수정</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" onClick={handleBack}>
|
||||
<List className="h-4 w-4 mr-2" />
|
||||
목록
|
||||
</Button>
|
||||
<Button variant="outline" onClick={handleCancel}>
|
||||
취소
|
||||
</Button>
|
||||
<Button onClick={handleSave}>저장</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
<PageHeader
|
||||
title="시공 상세"
|
||||
description="시공 정보를 확인하고 관리합니다"
|
||||
icon={Wrench}
|
||||
onBack={handleBack}
|
||||
actions={headerActions}
|
||||
/>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* 시공 정보 */}
|
||||
<Card>
|
||||
<CardHeader className="border-b pb-4">
|
||||
<CardTitle className="text-base font-medium">시공 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-4">
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
|
||||
{/* 시공번호 */}
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm text-muted-foreground">시공번호</label>
|
||||
<div className="font-medium">{detail.constructionNumber}</div>
|
||||
</div>
|
||||
|
||||
{/* 현장 */}
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm text-muted-foreground">현장</label>
|
||||
<div className="font-medium">{detail.siteName}</div>
|
||||
</div>
|
||||
|
||||
{/* 시공투입일 */}
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm text-muted-foreground">시공투입일</label>
|
||||
<div className="font-medium">{formatDate(detail.constructionStartDate)}</div>
|
||||
</div>
|
||||
|
||||
{/* 시공완료일 */}
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm text-muted-foreground">시공완료일</label>
|
||||
<div className="font-medium">{formatDate(detail.constructionEndDate)}</div>
|
||||
</div>
|
||||
|
||||
{/* 작업반장 */}
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm text-muted-foreground">작업반장</label>
|
||||
{isViewMode ? (
|
||||
<div className="font-medium">{formData.workTeamLeader || '-'}</div>
|
||||
) : (
|
||||
<Select
|
||||
value={formData.workTeamLeader}
|
||||
onValueChange={handleWorkTeamLeaderChange}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{MOCK_CM_WORK_TEAM_LEADERS.map((leader) => (
|
||||
<SelectItem key={leader.value} value={leader.label}>
|
||||
{leader.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 상태 */}
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm text-muted-foreground">상태</label>
|
||||
<div>
|
||||
<span
|
||||
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${CONSTRUCTION_MANAGEMENT_STATUS_STYLES[detail.status]}`}
|
||||
>
|
||||
{CONSTRUCTION_MANAGEMENT_STATUS_LABELS[detail.status]}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 작업자 정보 */}
|
||||
<Card>
|
||||
<CardHeader className="border-b pb-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-base font-medium">작업자 정보</CardTitle>
|
||||
{isEditMode && (
|
||||
<Button variant="outline" size="sm" onClick={handleAddWorkerInfo}>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
추가
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-4">
|
||||
{formData.workerInfoList.length === 0 ? (
|
||||
<div className="text-center text-muted-foreground py-8">
|
||||
{isViewMode ? '등록된 작업자 정보가 없습니다.' : '작업자 정보가 없습니다. 추가 버튼을 클릭하여 등록하세요.'}
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full border-collapse">
|
||||
<thead>
|
||||
<tr className="border-b">
|
||||
<th className="text-left py-2 px-3 text-sm font-medium w-16">번호</th>
|
||||
<th className="text-left py-2 px-3 text-sm font-medium w-40">작업일</th>
|
||||
<th className="text-left py-2 px-3 text-sm font-medium">작업자</th>
|
||||
{isEditMode && (
|
||||
<th className="text-center py-2 px-3 text-sm font-medium w-20">삭제</th>
|
||||
)}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{formData.workerInfoList.map((worker, index) => (
|
||||
<tr key={worker.id} className="border-b">
|
||||
<td className="py-2 px-3">{index + 1}</td>
|
||||
<td className="py-2 px-3">
|
||||
{isViewMode ? (
|
||||
<span>{worker.workDate || '-'}</span>
|
||||
) : (
|
||||
<Input
|
||||
type="date"
|
||||
value={worker.workDate}
|
||||
onChange={(e) =>
|
||||
handleWorkerInfoChange(worker.id, 'workDate', e.target.value)
|
||||
}
|
||||
className="w-full"
|
||||
/>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2 px-3">
|
||||
{isViewMode ? (
|
||||
<span>
|
||||
{worker.workers.length > 0
|
||||
? worker.workers
|
||||
.map((w) => MOCK_EMPLOYEES.find((e) => e.value === w)?.label || w)
|
||||
.join(', ')
|
||||
: '-'}
|
||||
</span>
|
||||
) : (
|
||||
<MultiSelectCombobox
|
||||
options={MOCK_EMPLOYEES}
|
||||
value={worker.workers}
|
||||
onChange={(value) =>
|
||||
handleWorkerInfoChange(worker.id, 'workers', value)
|
||||
}
|
||||
placeholder="작업자 선택"
|
||||
className="w-full"
|
||||
/>
|
||||
)}
|
||||
</td>
|
||||
{isEditMode && (
|
||||
<td className="py-2 px-3 text-center">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDeleteWorkerInfo(worker.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 공과 정보 */}
|
||||
<Card>
|
||||
<CardHeader className="border-b pb-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-base font-medium">공과 정보</CardTitle>
|
||||
{isEditMode && (
|
||||
<Button variant="outline" size="sm" onClick={handleAddWorkProgress}>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
추가
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-4">
|
||||
{formData.workProgressList.length === 0 ? (
|
||||
<div className="text-center text-muted-foreground py-8">
|
||||
{isViewMode ? '등록된 공과 정보가 없습니다.' : '공과 정보가 없습니다. 추가 버튼을 클릭하여 등록하세요.'}
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full border-collapse">
|
||||
<thead>
|
||||
<tr className="border-b">
|
||||
<th className="text-left py-2 px-3 text-sm font-medium w-16">번호</th>
|
||||
<th className="text-left py-2 px-3 text-sm font-medium w-48">일정</th>
|
||||
<th className="text-left py-2 px-3 text-sm font-medium">공과명</th>
|
||||
{isEditMode && (
|
||||
<th className="text-center py-2 px-3 text-sm font-medium w-20">삭제</th>
|
||||
)}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{formData.workProgressList.map((progress, index) => (
|
||||
<tr key={progress.id} className="border-b">
|
||||
<td className="py-2 px-3">{index + 1}</td>
|
||||
<td className="py-2 px-3">
|
||||
{isViewMode ? (
|
||||
<span>{progress.scheduleDate || '-'}</span>
|
||||
) : (
|
||||
<Input
|
||||
type="datetime-local"
|
||||
value={progress.scheduleDate.replace(' ', 'T')}
|
||||
onChange={(e) =>
|
||||
handleWorkProgressChange(
|
||||
progress.id,
|
||||
'scheduleDate',
|
||||
e.target.value.replace('T', ' ')
|
||||
)
|
||||
}
|
||||
className="w-full"
|
||||
/>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2 px-3">
|
||||
{isViewMode ? (
|
||||
<span>{progress.workName || '-'}</span>
|
||||
) : (
|
||||
<Input
|
||||
type="text"
|
||||
value={progress.workName}
|
||||
onChange={(e) =>
|
||||
handleWorkProgressChange(progress.id, 'workName', e.target.value)
|
||||
}
|
||||
placeholder="공과명을 입력하세요"
|
||||
className="w-full"
|
||||
/>
|
||||
)}
|
||||
</td>
|
||||
{isEditMode && (
|
||||
<td className="py-2 px-3 text-center">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDeleteWorkProgress(progress.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 발주서 영역 */}
|
||||
<Card>
|
||||
<CardHeader className="border-b pb-4">
|
||||
<CardTitle className="text-base font-medium">발주서</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileText className="h-5 w-5 text-muted-foreground" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleViewOrder}
|
||||
className="text-primary hover:underline font-medium"
|
||||
>
|
||||
{detail.orderNumber}
|
||||
</button>
|
||||
<span className="text-muted-foreground text-sm">(클릭하여 발주서 보기)</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 이슈 목록 / 이슈 보고 - 카드 2개 형태 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* 이슈 목록 카드 */}
|
||||
<Card
|
||||
className="cursor-pointer hover:bg-muted/50 transition-colors"
|
||||
onClick={() => router.push(`/ko/construction/project/issue-management?orderId=${detail.orderId}`)}
|
||||
>
|
||||
<CardContent className="pt-6 pb-6">
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-base font-medium">이슈 목록</h3>
|
||||
<p className="text-3xl font-bold">{detail.issueCount}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 이슈 보고 카드 */}
|
||||
<Card
|
||||
className="cursor-pointer hover:bg-muted/50 transition-colors"
|
||||
onClick={() => router.push(`/ko/construction/project/issue-management/new?orderId=${detail.orderId}`)}
|
||||
>
|
||||
<CardContent className="pt-6 pb-6">
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-base font-medium">이슈 보고</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
이슈를 등록하시면 공사담당자가 검토합니다
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 작업일지 */}
|
||||
<Card>
|
||||
<CardHeader className="border-b pb-4">
|
||||
<CardTitle className="text-base font-medium">작업일지</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-4">
|
||||
{isViewMode ? (
|
||||
<div className="min-h-[100px] whitespace-pre-wrap">
|
||||
{formData.workLogContent || '등록된 작업일지가 없습니다.'}
|
||||
</div>
|
||||
) : (
|
||||
<Textarea
|
||||
value={formData.workLogContent}
|
||||
onChange={(e) => handleWorkLogChange(e.target.value)}
|
||||
placeholder="작업일지를 입력하세요"
|
||||
className="min-h-[150px]"
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 사진 */}
|
||||
<Card>
|
||||
<CardHeader className="border-b pb-4">
|
||||
<CardTitle className="text-base font-medium">사진</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-4 space-y-4">
|
||||
{/* 업로드 버튼 - edit 모드에서만 */}
|
||||
{isEditMode && (
|
||||
<div>
|
||||
<label className="inline-flex items-center gap-2 px-4 py-2 border rounded-md cursor-pointer hover:bg-muted/50 transition-colors">
|
||||
<Upload className="h-4 w-4" />
|
||||
<span className="text-sm">사진 업로드</span>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
onChange={handlePhotoUpload}
|
||||
className="hidden"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 업로드된 사진 목록 */}
|
||||
{formData.photos.length > 0 && (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
{formData.photos.map((photo) => (
|
||||
<div key={photo.id} className="relative group">
|
||||
<img
|
||||
src={photo.url}
|
||||
alt={photo.name}
|
||||
className="w-full h-32 object-cover rounded-lg border"
|
||||
/>
|
||||
{isEditMode && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDeletePhoto(photo.id)}
|
||||
className="absolute top-1 right-1 p-1 bg-black/50 text-white rounded-full opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
<div className="text-xs text-muted-foreground truncate mt-1">
|
||||
{photo.name}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{formData.photos.length === 0 && (
|
||||
<div className="text-center text-muted-foreground py-4">
|
||||
업로드된 사진이 없습니다.
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 시공 완료 버튼 - edit 모드에서만 */}
|
||||
{isEditMode && detail.status === 'in_progress' && (
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
size="lg"
|
||||
onClick={() => setShowCompleteDialog(true)}
|
||||
disabled={!canComplete}
|
||||
>
|
||||
시공 완료
|
||||
</Button>
|
||||
{!canComplete && (
|
||||
<span className="ml-3 text-sm text-muted-foreground self-center">
|
||||
* 작업일지와 사진을 모두 등록해야 시공 완료가 가능합니다.
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 발주서 모달 */}
|
||||
{orderData && (
|
||||
<OrderDocumentModal
|
||||
open={showOrderModal}
|
||||
onOpenChange={setShowOrderModal}
|
||||
order={orderData}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 시공 완료 확인 다이얼로그 */}
|
||||
<AlertDialog open={showCompleteDialog} onOpenChange={setShowCompleteDialog}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>시공 완료</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
시공을 완료하시겠습니까? 완료 후에는 상태를 변경할 수 없습니다.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>취소</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleComplete}>완료</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,716 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useMemo, useCallback, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { HardHat, Pencil, Clock, CheckCircle } 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, FilterFieldConfig, FilterValues } from '@/components/templates/IntegratedListTemplateV2';
|
||||
import { MobileCard } from '@/components/molecules/MobileCard';
|
||||
import { DateRangeSelector } from '@/components/molecules/DateRangeSelector';
|
||||
import { ScheduleCalendar, ScheduleEvent, DayBadge } from '@/components/common/ScheduleCalendar';
|
||||
import { toast } from 'sonner';
|
||||
import { isSameDay, startOfDay, parseISO } from 'date-fns';
|
||||
import type {
|
||||
ConstructionManagement,
|
||||
ConstructionManagementStats,
|
||||
} from './types';
|
||||
import {
|
||||
CONSTRUCTION_MANAGEMENT_STATUS_OPTIONS,
|
||||
CONSTRUCTION_MANAGEMENT_SORT_OPTIONS,
|
||||
CONSTRUCTION_MANAGEMENT_STATUS_STYLES,
|
||||
CONSTRUCTION_MANAGEMENT_STATUS_LABELS,
|
||||
MOCK_CM_PARTNERS,
|
||||
MOCK_CM_SITES,
|
||||
MOCK_CM_CONSTRUCTION_PM,
|
||||
MOCK_CM_WORK_TEAM_LEADERS,
|
||||
getConstructionScheduleColor,
|
||||
} from './types';
|
||||
import {
|
||||
getConstructionManagementList,
|
||||
getConstructionManagementStats,
|
||||
} from './actions';
|
||||
|
||||
// 테이블 컬럼 정의
|
||||
// 체크박스, 번호, 시공번호, 거래처, 현장명, 공사PM, 작업반장, 작업자, 시공투입일, 시공완료일, 상태, 작업
|
||||
const tableColumns: TableColumn[] = [
|
||||
{ key: 'no', label: '번호', className: 'w-[50px] text-center' },
|
||||
{ key: 'constructionNumber', label: '시공번호', className: 'w-[100px]' },
|
||||
{ key: 'partnerName', label: '거래처', className: 'w-[100px]' },
|
||||
{ key: 'siteName', label: '현장명', className: 'min-w-[120px]' },
|
||||
{ key: 'constructionPM', label: '공사PM', className: 'w-[80px]' },
|
||||
{ key: 'workTeamLeader', label: '작업반장', className: 'w-[80px]' },
|
||||
{ key: 'worker', label: '작업자', className: 'w-[80px]' },
|
||||
{ key: 'constructionStartDate', label: '시공투입일', className: 'w-[100px]' },
|
||||
{ key: 'constructionEndDate', label: '시공완료일', className: 'w-[100px]' },
|
||||
{ key: 'status', label: '상태', className: 'w-[80px] text-center' },
|
||||
{ key: 'actions', label: '작업', className: 'w-[80px] text-center' },
|
||||
];
|
||||
|
||||
interface ConstructionManagementListClientProps {
|
||||
initialData?: ConstructionManagement[];
|
||||
initialStats?: ConstructionManagementStats;
|
||||
}
|
||||
|
||||
export default function ConstructionManagementListClient({
|
||||
initialData = [],
|
||||
initialStats,
|
||||
}: ConstructionManagementListClientProps) {
|
||||
const router = useRouter();
|
||||
|
||||
// 상태
|
||||
const [constructions, setConstructions] = useState<ConstructionManagement[]>(initialData);
|
||||
const [stats, setStats] = useState<ConstructionManagementStats | null>(initialStats || null);
|
||||
const [searchValue, setSearchValue] = useState('');
|
||||
// 다중선택 필터 (빈 배열 = 전체)
|
||||
const [partnerFilters, setPartnerFilters] = useState<string[]>([]);
|
||||
const [siteNameFilters, setSiteNameFilters] = useState<string[]>([]);
|
||||
const [constructionPMFilters, setConstructionPMFilters] = useState<string[]>([]);
|
||||
const [workTeamFilters, setWorkTeamFilters] = useState<string[]>([]);
|
||||
// 달력용 필터
|
||||
const [calendarSiteFilters, setCalendarSiteFilters] = useState<string[]>([]);
|
||||
const [calendarWorkTeamFilters, setCalendarWorkTeamFilters] = useState<string[]>([]);
|
||||
const [statusFilter, setStatusFilter] = useState<string>('all');
|
||||
const [sortBy, setSortBy] = useState<string>('latest');
|
||||
const [startDate, setStartDate] = useState<string>('');
|
||||
const [endDate, setEndDate] = useState<string>('');
|
||||
const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set());
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [activeStatTab, setActiveStatTab] = useState<'all' | 'in_progress' | 'completed'>('all');
|
||||
const [selectedCalendarDate, setSelectedCalendarDate] = useState<Date | null>(null);
|
||||
const [calendarDate, setCalendarDate] = useState<Date>(new Date());
|
||||
const itemsPerPage = 20;
|
||||
|
||||
// 데이터 로드
|
||||
const loadData = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const [listResult, statsResult] = await Promise.all([
|
||||
getConstructionManagementList({
|
||||
size: 1000,
|
||||
startDate: startDate || undefined,
|
||||
endDate: endDate || undefined,
|
||||
}),
|
||||
getConstructionManagementStats(),
|
||||
]);
|
||||
|
||||
if (listResult.success && listResult.data) {
|
||||
setConstructions(listResult.data.items);
|
||||
}
|
||||
if (statsResult.success && statsResult.data) {
|
||||
setStats(statsResult.data);
|
||||
}
|
||||
} catch {
|
||||
toast.error('데이터 로드에 실패했습니다.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [startDate, endDate]);
|
||||
|
||||
// 초기 데이터가 없으면 로드
|
||||
useEffect(() => {
|
||||
if (initialData.length === 0) {
|
||||
loadData();
|
||||
}
|
||||
}, [initialData.length, loadData]);
|
||||
|
||||
// 다중선택 필터 옵션 변환 (MultiSelectCombobox용)
|
||||
const siteOptions: MultiSelectOption[] = useMemo(() =>
|
||||
MOCK_CM_SITES.map(s => ({ value: s.value, label: s.label })),
|
||||
[]);
|
||||
const workTeamOptions: MultiSelectOption[] = useMemo(() =>
|
||||
MOCK_CM_WORK_TEAM_LEADERS.map(l => ({ value: l.value, label: l.label })),
|
||||
[]);
|
||||
const partnerOptions: MultiSelectOption[] = useMemo(() =>
|
||||
MOCK_CM_PARTNERS.map(p => ({ value: p.value, label: p.label })),
|
||||
[]);
|
||||
const constructionPMOptions: MultiSelectOption[] = useMemo(() =>
|
||||
MOCK_CM_CONSTRUCTION_PM.map(pm => ({ value: pm.value, label: pm.label })),
|
||||
[]);
|
||||
|
||||
// 달력용 이벤트 데이터 변환 (필터 적용)
|
||||
// 색상: 작업반장별 고정 색상
|
||||
const calendarEvents: ScheduleEvent[] = useMemo(() => {
|
||||
return constructions
|
||||
.filter((item) => {
|
||||
// 현장 필터 (빈 배열 = 전체)
|
||||
if (calendarSiteFilters.length > 0) {
|
||||
const matchingSite = MOCK_CM_SITES.find((s) => s.label === item.siteName);
|
||||
if (!matchingSite || !calendarSiteFilters.includes(matchingSite.value)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 작업반장 필터 (빈 배열 = 전체)
|
||||
if (calendarWorkTeamFilters.length > 0) {
|
||||
const matchingLeader = MOCK_CM_WORK_TEAM_LEADERS.find((l) => l.label === item.workTeamLeader);
|
||||
if (!matchingLeader || !calendarWorkTeamFilters.includes(matchingLeader.value)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
})
|
||||
.map((item) => ({
|
||||
id: item.id,
|
||||
title: `${item.workTeamLeader} - ${item.siteName} / ${item.constructionNumber}`,
|
||||
startDate: item.periodStart,
|
||||
endDate: item.periodEnd,
|
||||
color: getConstructionScheduleColor(item.workTeamLeader),
|
||||
status: item.status,
|
||||
data: item,
|
||||
}));
|
||||
}, [constructions, calendarSiteFilters, calendarWorkTeamFilters]);
|
||||
|
||||
// 달력용 뱃지 데이터 - 사용하지 않음
|
||||
const calendarBadges: DayBadge[] = [];
|
||||
|
||||
// 필터링된 데이터
|
||||
const filteredConstructions = useMemo(() => {
|
||||
return constructions.filter((item) => {
|
||||
// 상태 탭 필터
|
||||
if (activeStatTab === 'in_progress' && item.status !== 'in_progress') return false;
|
||||
if (activeStatTab === 'completed' && item.status !== 'completed') return false;
|
||||
|
||||
// 상태 필터
|
||||
if (statusFilter !== 'all' && item.status !== statusFilter) return false;
|
||||
|
||||
// 거래처 필터 (다중선택)
|
||||
if (partnerFilters.length > 0) {
|
||||
const matchingPartner = MOCK_CM_PARTNERS.find((p) => p.label === item.partnerName);
|
||||
if (!matchingPartner || !partnerFilters.includes(matchingPartner.value)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 현장명 필터 (다중선택)
|
||||
if (siteNameFilters.length > 0) {
|
||||
const matchingSite = MOCK_CM_SITES.find((s) => s.label === item.siteName);
|
||||
if (!matchingSite || !siteNameFilters.includes(matchingSite.value)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 공사PM 필터 (다중선택)
|
||||
if (constructionPMFilters.length > 0) {
|
||||
const matchingPM = MOCK_CM_CONSTRUCTION_PM.find((p) => p.label === item.constructionPM);
|
||||
if (!matchingPM || !constructionPMFilters.includes(matchingPM.value)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 작업반장 필터 (다중선택)
|
||||
if (workTeamFilters.length > 0) {
|
||||
const matchingLeader = MOCK_CM_WORK_TEAM_LEADERS.find((l) => l.label === item.workTeamLeader);
|
||||
if (!matchingLeader || !workTeamFilters.includes(matchingLeader.value)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 달력 날짜 필터 (시간 무시, 날짜만 비교)
|
||||
if (selectedCalendarDate) {
|
||||
const itemStart = startOfDay(parseISO(item.periodStart));
|
||||
const itemEnd = startOfDay(parseISO(item.periodEnd));
|
||||
const selected = startOfDay(selectedCalendarDate);
|
||||
|
||||
// 선택된 날짜가 기간 내에 있는지 확인
|
||||
if (selected < itemStart || selected > itemEnd) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 검색 필터
|
||||
if (searchValue) {
|
||||
const search = searchValue.toLowerCase();
|
||||
return (
|
||||
item.constructionNumber.toLowerCase().includes(search) ||
|
||||
item.partnerName.toLowerCase().includes(search) ||
|
||||
item.siteName.toLowerCase().includes(search) ||
|
||||
item.workTeamLeader.toLowerCase().includes(search) ||
|
||||
item.worker.toLowerCase().includes(search)
|
||||
);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}, [constructions, activeStatTab, statusFilter, partnerFilters, siteNameFilters, constructionPMFilters, workTeamFilters, selectedCalendarDate, searchValue]);
|
||||
|
||||
// 정렬
|
||||
const sortedConstructions = useMemo(() => {
|
||||
const sorted = [...filteredConstructions];
|
||||
switch (sortBy) {
|
||||
case 'latest':
|
||||
sorted.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
||||
break;
|
||||
case 'register':
|
||||
sorted.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
|
||||
break;
|
||||
case 'completionDateDesc':
|
||||
sorted.sort((a, b) => {
|
||||
if (!a.constructionEndDate) return 1;
|
||||
if (!b.constructionEndDate) return -1;
|
||||
return new Date(b.constructionEndDate).getTime() - new Date(a.constructionEndDate).getTime();
|
||||
});
|
||||
break;
|
||||
case 'partnerNameAsc':
|
||||
sorted.sort((a, b) => a.partnerName.localeCompare(b.partnerName, 'ko'));
|
||||
break;
|
||||
case 'partnerNameDesc':
|
||||
sorted.sort((a, b) => b.partnerName.localeCompare(a.partnerName, 'ko'));
|
||||
break;
|
||||
}
|
||||
return sorted;
|
||||
}, [filteredConstructions, sortBy]);
|
||||
|
||||
// 페이지네이션
|
||||
const totalPages = Math.ceil(sortedConstructions.length / itemsPerPage);
|
||||
const paginatedData = useMemo(() => {
|
||||
const start = (currentPage - 1) * itemsPerPage;
|
||||
return sortedConstructions.slice(start, start + itemsPerPage);
|
||||
}, [sortedConstructions, currentPage, itemsPerPage]);
|
||||
|
||||
// 핸들러
|
||||
const handleSearchChange = useCallback((value: string) => {
|
||||
setSearchValue(value);
|
||||
setCurrentPage(1);
|
||||
}, []);
|
||||
|
||||
const handleToggleSelection = useCallback((id: string) => {
|
||||
setSelectedItems((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(id)) {
|
||||
newSet.delete(id);
|
||||
} else {
|
||||
newSet.add(id);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleToggleSelectAll = useCallback(() => {
|
||||
if (selectedItems.size === paginatedData.length) {
|
||||
setSelectedItems(new Set());
|
||||
} else {
|
||||
setSelectedItems(new Set(paginatedData.map((c) => c.id)));
|
||||
}
|
||||
}, [selectedItems.size, paginatedData]);
|
||||
|
||||
const handleRowClick = useCallback(
|
||||
(item: ConstructionManagement) => {
|
||||
router.push(`/ko/construction/project/construction-management/${item.id}`);
|
||||
},
|
||||
[router]
|
||||
);
|
||||
|
||||
const handleEdit = useCallback(
|
||||
(e: React.MouseEvent, itemId: string) => {
|
||||
e.stopPropagation();
|
||||
router.push(`/ko/construction/project/construction-management/${itemId}/edit`);
|
||||
},
|
||||
[router]
|
||||
);
|
||||
|
||||
// 달력 이벤트 핸들러
|
||||
const handleCalendarDateClick = useCallback((date: Date) => {
|
||||
// 같은 날짜 클릭 시 선택 해제
|
||||
if (selectedCalendarDate && isSameDay(selectedCalendarDate, date)) {
|
||||
setSelectedCalendarDate(null);
|
||||
} else {
|
||||
setSelectedCalendarDate(date);
|
||||
}
|
||||
setCurrentPage(1);
|
||||
}, [selectedCalendarDate]);
|
||||
|
||||
const handleCalendarEventClick = useCallback((event: ScheduleEvent) => {
|
||||
if (event.data) {
|
||||
router.push(`/ko/construction/project/construction-management/${event.id}`);
|
||||
}
|
||||
}, [router]);
|
||||
|
||||
const handleCalendarMonthChange = useCallback((date: Date) => {
|
||||
setCalendarDate(date);
|
||||
}, []);
|
||||
|
||||
// 날짜 포맷
|
||||
const formatDate = (dateStr: string | null) => {
|
||||
if (!dateStr) return '-';
|
||||
return dateStr.split('T')[0];
|
||||
};
|
||||
|
||||
// 테이블 행 렌더링
|
||||
const renderTableRow = useCallback(
|
||||
(item: ConstructionManagement, index: number, globalIndex: number) => {
|
||||
const isSelected = selectedItems.has(item.id);
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
key={item.id}
|
||||
className="cursor-pointer hover:bg-muted/50"
|
||||
onClick={() => handleRowClick(item)}
|
||||
>
|
||||
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={() => handleToggleSelection(item.id)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-center text-muted-foreground">{globalIndex}</TableCell>
|
||||
<TableCell>{item.constructionNumber}</TableCell>
|
||||
<TableCell>{item.partnerName}</TableCell>
|
||||
<TableCell>{item.siteName}</TableCell>
|
||||
<TableCell>{item.constructionPM}</TableCell>
|
||||
<TableCell>{item.workTeamLeader}</TableCell>
|
||||
<TableCell>{item.worker}</TableCell>
|
||||
<TableCell>{formatDate(item.constructionStartDate)}</TableCell>
|
||||
<TableCell>{formatDate(item.constructionEndDate)}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<span className={`px-2 py-1 rounded-full text-xs font-medium ${CONSTRUCTION_MANAGEMENT_STATUS_STYLES[item.status]}`}>
|
||||
{CONSTRUCTION_MANAGEMENT_STATUS_LABELS[item.status]}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{isSelected && (
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={(e) => handleEdit(e, item.id)}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
},
|
||||
[selectedItems, handleToggleSelection, handleRowClick, handleEdit]
|
||||
);
|
||||
|
||||
// 모바일 카드 렌더링
|
||||
const renderMobileCard = useCallback(
|
||||
(item: ConstructionManagement, index: number, globalIndex: number, isSelected: boolean, onToggle: () => void) => {
|
||||
return (
|
||||
<MobileCard
|
||||
title={item.siteName}
|
||||
subtitle={item.constructionNumber}
|
||||
badge={CONSTRUCTION_MANAGEMENT_STATUS_LABELS[item.status]}
|
||||
badgeVariant="secondary"
|
||||
badgeClassName={CONSTRUCTION_MANAGEMENT_STATUS_STYLES[item.status]}
|
||||
isSelected={isSelected}
|
||||
onToggle={onToggle}
|
||||
onClick={() => handleRowClick(item)}
|
||||
details={[
|
||||
{ label: '거래처', value: item.partnerName },
|
||||
{ label: '작업반장', value: item.workTeamLeader },
|
||||
{ label: '작업자', value: item.worker || '-' },
|
||||
{ label: '시공투입일', value: formatDate(item.constructionStartDate) },
|
||||
{ label: '시공완료일', value: formatDate(item.constructionEndDate) },
|
||||
]}
|
||||
/>
|
||||
);
|
||||
},
|
||||
[handleRowClick]
|
||||
);
|
||||
|
||||
// 헤더 액션 (DateRangeSelector)
|
||||
const headerActions = (
|
||||
<DateRangeSelector
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
onStartDateChange={setStartDate}
|
||||
onEndDateChange={setEndDate}
|
||||
/>
|
||||
);
|
||||
|
||||
// 통계 카드 클릭 핸들러
|
||||
const handleStatClick = useCallback((tab: 'all' | 'in_progress' | 'completed') => {
|
||||
setActiveStatTab(tab);
|
||||
setCurrentPage(1);
|
||||
}, []);
|
||||
|
||||
// 통계 카드 데이터
|
||||
const statsCardsData: StatCard[] = [
|
||||
{
|
||||
label: '시공진행',
|
||||
value: stats?.inProgress ?? 0,
|
||||
icon: Clock,
|
||||
iconColor: 'text-yellow-600',
|
||||
onClick: () => handleStatClick('in_progress'),
|
||||
isActive: activeStatTab === 'in_progress',
|
||||
},
|
||||
{
|
||||
label: '시공완료',
|
||||
value: stats?.completed ?? 0,
|
||||
icon: CheckCircle,
|
||||
iconColor: 'text-green-600',
|
||||
onClick: () => handleStatClick('completed'),
|
||||
isActive: activeStatTab === 'completed',
|
||||
},
|
||||
];
|
||||
|
||||
// 모바일 필터 설정
|
||||
const mobileFilterFields: FilterFieldConfig[] = [
|
||||
{
|
||||
key: 'partners',
|
||||
label: '거래처',
|
||||
type: 'multi',
|
||||
options: partnerOptions,
|
||||
},
|
||||
{
|
||||
key: 'sites',
|
||||
label: '현장명',
|
||||
type: 'multi',
|
||||
options: siteOptions,
|
||||
},
|
||||
{
|
||||
key: 'constructionPMs',
|
||||
label: '공사PM',
|
||||
type: 'multi',
|
||||
options: constructionPMOptions,
|
||||
},
|
||||
{
|
||||
key: 'workTeamLeaders',
|
||||
label: '작업반장',
|
||||
type: 'multi',
|
||||
options: workTeamOptions,
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
label: '상태',
|
||||
type: 'single',
|
||||
options: CONSTRUCTION_MANAGEMENT_STATUS_OPTIONS.filter(opt => opt.value !== 'all'),
|
||||
},
|
||||
{
|
||||
key: 'sortBy',
|
||||
label: '정렬',
|
||||
type: 'single',
|
||||
options: CONSTRUCTION_MANAGEMENT_SORT_OPTIONS,
|
||||
allOptionLabel: '최신순',
|
||||
},
|
||||
];
|
||||
|
||||
// 모바일 필터 값
|
||||
const mobileFilterValues: FilterValues = {
|
||||
partners: partnerFilters,
|
||||
sites: siteNameFilters,
|
||||
constructionPMs: constructionPMFilters,
|
||||
workTeamLeaders: workTeamFilters,
|
||||
status: statusFilter,
|
||||
sortBy: sortBy,
|
||||
};
|
||||
|
||||
// 모바일 필터 변경 핸들러
|
||||
const handleMobileFilterChange = (key: string, value: string | string[]) => {
|
||||
switch (key) {
|
||||
case 'partners':
|
||||
setPartnerFilters(value as string[]);
|
||||
break;
|
||||
case 'sites':
|
||||
setSiteNameFilters(value as string[]);
|
||||
break;
|
||||
case 'constructionPMs':
|
||||
setConstructionPMFilters(value as string[]);
|
||||
break;
|
||||
case 'workTeamLeaders':
|
||||
setWorkTeamFilters(value as string[]);
|
||||
break;
|
||||
case 'status':
|
||||
setStatusFilter(value as string);
|
||||
break;
|
||||
case 'sortBy':
|
||||
setSortBy(value as string);
|
||||
break;
|
||||
}
|
||||
setCurrentPage(1);
|
||||
};
|
||||
|
||||
// 모바일 필터 초기화 핸들러
|
||||
const handleMobileFilterReset = useCallback(() => {
|
||||
setPartnerFilters([]);
|
||||
setSiteNameFilters([]);
|
||||
setConstructionPMFilters([]);
|
||||
setWorkTeamFilters([]);
|
||||
setStatusFilter('all');
|
||||
setSortBy('latest');
|
||||
setCurrentPage(1);
|
||||
}, []);
|
||||
|
||||
// 테이블 헤더 액션 (기획서 요구사항)
|
||||
// 거래처, 현장명, 공사PM, 작업반장, 상태, 정렬
|
||||
const tableHeaderActions = (
|
||||
<div className="flex items-center gap-2 flex-wrap justify-end w-full">
|
||||
{/* PC용 개별 필터 */}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{/* 1. 거래처 필터 (다중선택) */}
|
||||
<MultiSelectCombobox
|
||||
options={partnerOptions}
|
||||
value={partnerFilters}
|
||||
onChange={setPartnerFilters}
|
||||
placeholder="거래처"
|
||||
searchPlaceholder="거래처 검색..."
|
||||
className="w-[120px]"
|
||||
/>
|
||||
|
||||
{/* 2. 현장명 필터 (다중선택) */}
|
||||
<MultiSelectCombobox
|
||||
options={siteOptions}
|
||||
value={siteNameFilters}
|
||||
onChange={setSiteNameFilters}
|
||||
placeholder="현장명"
|
||||
searchPlaceholder="현장명 검색..."
|
||||
className="w-[140px]"
|
||||
/>
|
||||
|
||||
{/* 3. 공사PM 필터 (다중선택) */}
|
||||
<MultiSelectCombobox
|
||||
options={constructionPMOptions}
|
||||
value={constructionPMFilters}
|
||||
onChange={setConstructionPMFilters}
|
||||
placeholder="공사PM"
|
||||
searchPlaceholder="공사PM 검색..."
|
||||
className="w-[120px]"
|
||||
/>
|
||||
|
||||
{/* 4. 작업반장 필터 (다중선택) */}
|
||||
<MultiSelectCombobox
|
||||
options={workTeamOptions}
|
||||
value={workTeamFilters}
|
||||
onChange={setWorkTeamFilters}
|
||||
placeholder="작업반장"
|
||||
searchPlaceholder="작업반장 검색..."
|
||||
className="w-[120px]"
|
||||
/>
|
||||
|
||||
{/* 5. 상태 필터 (단일선택) */}
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<SelectTrigger className="w-[100px]">
|
||||
<SelectValue placeholder="상태" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{CONSTRUCTION_MANAGEMENT_STATUS_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* 6. 정렬 (단일선택) */}
|
||||
<Select value={sortBy} onValueChange={setSortBy}>
|
||||
<SelectTrigger className="w-[140px]">
|
||||
<SelectValue placeholder="정렬" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{CONSTRUCTION_MANAGEMENT_SORT_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 달력 날짜 필터 초기화 */}
|
||||
{selectedCalendarDate && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setSelectedCalendarDate(null)}
|
||||
>
|
||||
날짜 필터 해제
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
// 달력 필터 슬롯 (현장 + 작업반장 - 다중선택)
|
||||
const calendarFilterSlot = (
|
||||
<div className="flex items-center gap-2">
|
||||
{/* 현장 필터 (다중선택) */}
|
||||
<MultiSelectCombobox
|
||||
options={siteOptions}
|
||||
value={calendarSiteFilters}
|
||||
onChange={setCalendarSiteFilters}
|
||||
placeholder="현장"
|
||||
searchPlaceholder="현장 검색..."
|
||||
className="w-[160px]"
|
||||
/>
|
||||
{/* 작업반장 필터 (다중선택) */}
|
||||
<MultiSelectCombobox
|
||||
options={workTeamOptions}
|
||||
value={calendarWorkTeamFilters}
|
||||
onChange={setCalendarWorkTeamFilters}
|
||||
placeholder="작업반장"
|
||||
searchPlaceholder="작업반장 검색..."
|
||||
className="w-[130px]"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<IntegratedListTemplateV2
|
||||
title="시공관리"
|
||||
description="시공 스케줄 및 목록을 관리합니다"
|
||||
icon={HardHat}
|
||||
headerActions={headerActions}
|
||||
stats={statsCardsData}
|
||||
tableHeaderActions={tableHeaderActions}
|
||||
filterConfig={mobileFilterFields}
|
||||
filterValues={mobileFilterValues}
|
||||
onFilterChange={handleMobileFilterChange}
|
||||
onFilterReset={handleMobileFilterReset}
|
||||
filterTitle="시공관리 필터"
|
||||
searchValue={searchValue}
|
||||
onSearchChange={handleSearchChange}
|
||||
searchPlaceholder="시공번호, 거래처, 현장명, 작업반장, 작업자 검색"
|
||||
tableColumns={tableColumns}
|
||||
data={paginatedData}
|
||||
allData={sortedConstructions}
|
||||
getItemId={(item) => item.id}
|
||||
renderTableRow={renderTableRow}
|
||||
renderMobileCard={renderMobileCard}
|
||||
selectedItems={selectedItems}
|
||||
onToggleSelection={handleToggleSelection}
|
||||
onToggleSelectAll={handleToggleSelectAll}
|
||||
pagination={{
|
||||
currentPage,
|
||||
totalPages,
|
||||
totalItems: sortedConstructions.length,
|
||||
itemsPerPage,
|
||||
onPageChange: setCurrentPage,
|
||||
}}
|
||||
// 달력 섹션 추가
|
||||
beforeTableContent={
|
||||
<div className="w-full flex-shrink-0 mb-6">
|
||||
<ScheduleCalendar
|
||||
events={calendarEvents}
|
||||
badges={calendarBadges}
|
||||
currentDate={calendarDate}
|
||||
selectedDate={selectedCalendarDate}
|
||||
onDateClick={handleCalendarDateClick}
|
||||
onEventClick={handleCalendarEventClick}
|
||||
onMonthChange={handleCalendarMonthChange}
|
||||
titleSlot="시공 스케줄"
|
||||
filterSlot={calendarFilterSlot}
|
||||
maxEventsPerDay={5}
|
||||
weekStartsOn={0}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { ChevronUp, ChevronDown } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import type {
|
||||
DetailCategory,
|
||||
ConstructionItem,
|
||||
IssueItem,
|
||||
} from './types';
|
||||
import {
|
||||
DETAIL_CATEGORY_LABELS,
|
||||
CONSTRUCTION_STATUS_LABELS,
|
||||
ISSUE_STATUS_LABELS,
|
||||
} from './types';
|
||||
|
||||
interface DetailAccordionProps {
|
||||
categories: DetailCategory[];
|
||||
selectedDetailId?: string | null;
|
||||
onDetailSelect?: (id: string) => void;
|
||||
}
|
||||
|
||||
export default function DetailAccordion({
|
||||
categories,
|
||||
selectedDetailId,
|
||||
onDetailSelect
|
||||
}: DetailAccordionProps) {
|
||||
// 첫 번째 카테고리만 기본 열림
|
||||
const [openCategories, setOpenCategories] = useState<string[]>(
|
||||
categories.length > 0 ? [categories[0].type] : []
|
||||
);
|
||||
|
||||
const toggleCategory = (type: string) => {
|
||||
setOpenCategories((prev) =>
|
||||
prev.includes(type) ? prev.filter((t) => t !== type) : [...prev, type]
|
||||
);
|
||||
};
|
||||
|
||||
if (categories.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-24 text-sm text-muted-foreground">
|
||||
상세 정보가 없습니다.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{categories.map((category) => (
|
||||
<CategoryAccordionItem
|
||||
key={category.type}
|
||||
category={category}
|
||||
isOpen={openCategories.includes(category.type)}
|
||||
onToggle={() => toggleCategory(category.type)}
|
||||
selectedDetailId={selectedDetailId}
|
||||
onDetailSelect={onDetailSelect}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface CategoryAccordionItemProps {
|
||||
category: DetailCategory;
|
||||
isOpen: boolean;
|
||||
onToggle: () => void;
|
||||
selectedDetailId?: string | null;
|
||||
onDetailSelect?: (id: string) => void;
|
||||
}
|
||||
|
||||
function CategoryAccordionItem({
|
||||
category,
|
||||
isOpen,
|
||||
onToggle,
|
||||
selectedDetailId,
|
||||
onDetailSelect,
|
||||
}: CategoryAccordionItemProps) {
|
||||
const label = DETAIL_CATEGORY_LABELS[category.type];
|
||||
|
||||
return (
|
||||
<div className="border rounded-lg overflow-hidden bg-card">
|
||||
{/* 아코디언 헤더 */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
className="w-full flex items-center justify-between p-3 hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-foreground">{label}</h4>
|
||||
<p className="text-xs text-muted-foreground">{category.count}건</p>
|
||||
</div>
|
||||
{isOpen ? (
|
||||
<ChevronUp className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* 아코디언 컨텐츠 */}
|
||||
{isOpen && (
|
||||
<div className="border-t p-2 space-y-2 bg-muted/30 max-h-[300px] overflow-y-auto">
|
||||
{category.type === 'construction' &&
|
||||
category.constructionItems?.map((item) => (
|
||||
<ConstructionCard
|
||||
key={item.id}
|
||||
item={item}
|
||||
isSelected={selectedDetailId === item.id}
|
||||
onClick={() => onDetailSelect?.(item.id)}
|
||||
/>
|
||||
))}
|
||||
{category.type === 'issue' &&
|
||||
category.issueItems?.map((item) => (
|
||||
<IssueCard
|
||||
key={item.id}
|
||||
item={item}
|
||||
isSelected={selectedDetailId === item.id}
|
||||
onClick={() => onDetailSelect?.(item.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ConstructionCardProps {
|
||||
item: ConstructionItem;
|
||||
isSelected?: boolean;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
function ConstructionCard({ item, isSelected, onClick }: ConstructionCardProps) {
|
||||
const statusLabel = CONSTRUCTION_STATUS_LABELS[item.status];
|
||||
const isInProgress = item.status === 'in_progress';
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'bg-card rounded-lg p-3 border cursor-pointer transition-all hover:shadow-md',
|
||||
isSelected && 'ring-2 ring-primary border-primary'
|
||||
)}
|
||||
onClick={onClick}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium text-foreground">{item.number}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
시공투입일: {item.inputDate}
|
||||
</p>
|
||||
</div>
|
||||
<Badge
|
||||
variant={isInProgress ? 'default' : 'secondary'}
|
||||
className={cn(
|
||||
'text-xs shrink-0',
|
||||
isInProgress && 'bg-blue-500 hover:bg-blue-600'
|
||||
)}
|
||||
>
|
||||
{statusLabel}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface IssueCardProps {
|
||||
item: IssueItem;
|
||||
isSelected?: boolean;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
function IssueCard({ item, isSelected, onClick }: IssueCardProps) {
|
||||
const statusLabel = ISSUE_STATUS_LABELS[item.status];
|
||||
const isOpen = item.status === 'open';
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'bg-card rounded-lg p-3 border cursor-pointer transition-all hover:shadow-md',
|
||||
isSelected && 'ring-2 ring-primary border-primary'
|
||||
)}
|
||||
onClick={onClick}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium text-foreground">{item.number}</p>
|
||||
<p className="text-xs text-muted-foreground">{item.title}</p>
|
||||
</div>
|
||||
<Badge
|
||||
variant={isOpen ? 'destructive' : 'secondary'}
|
||||
className="text-xs shrink-0"
|
||||
>
|
||||
{statusLabel}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
'use client';
|
||||
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { StageDetail, StageCardStatus } from './types';
|
||||
import { DETAIL_CONFIG } from './types';
|
||||
|
||||
interface DetailCardProps {
|
||||
detail: StageDetail;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export default function DetailCard({ detail, onClick }: DetailCardProps) {
|
||||
const config = DETAIL_CONFIG[detail.type];
|
||||
|
||||
// 상태 뱃지 색상
|
||||
const getStatusBadge = (status?: StageCardStatus) => {
|
||||
if (!status) return null;
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
return <Badge variant="secondary" className="text-xs">완료</Badge>;
|
||||
case 'in_progress':
|
||||
return <Badge className="text-xs bg-yellow-500">진행중</Badge>;
|
||||
case 'waiting':
|
||||
return <Badge variant="outline" className="text-xs">대기</Badge>;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'p-3 bg-card rounded-lg border cursor-pointer transition-all hover:shadow-md'
|
||||
)}
|
||||
onClick={onClick}
|
||||
>
|
||||
{/* 헤더: 상세 타입 + 상태 뱃지 */}
|
||||
<div className="flex items-center justify-between gap-2 mb-2">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
{config.label}
|
||||
</span>
|
||||
{getStatusBadge(detail.status)}
|
||||
</div>
|
||||
|
||||
{/* 제목 */}
|
||||
<h4 className="text-sm font-medium text-foreground mb-2 line-clamp-1">
|
||||
{detail.title}
|
||||
</h4>
|
||||
|
||||
{/* 날짜 또는 담당자 */}
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{detail.date && (
|
||||
<div className="flex justify-between">
|
||||
<span>{config.dateLabel}</span>
|
||||
<span>{detail.date.replace(/-/g, '.')}</span>
|
||||
</div>
|
||||
)}
|
||||
{detail.pm && (
|
||||
<div className="flex justify-between">
|
||||
<span>{config.dateLabel}</span>
|
||||
<span>{detail.pm}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
'use client';
|
||||
|
||||
import { ReactNode } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
|
||||
interface KanbanColumnProps {
|
||||
title: string;
|
||||
count?: number;
|
||||
headerAction?: ReactNode;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
emptyMessage?: string;
|
||||
isEmpty?: boolean;
|
||||
}
|
||||
|
||||
export default function KanbanColumn({
|
||||
title,
|
||||
count,
|
||||
headerAction,
|
||||
children,
|
||||
className,
|
||||
emptyMessage = '항목이 없습니다.',
|
||||
isEmpty = false,
|
||||
}: KanbanColumnProps) {
|
||||
return (
|
||||
<div className={cn('flex flex-col flex-1 min-w-0 bg-muted/30 rounded-lg', className)}>
|
||||
{/* 컬럼 헤더 */}
|
||||
<div className="flex items-center justify-between p-3 border-b">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-sm font-semibold text-foreground">{title}</h3>
|
||||
{count !== undefined && (
|
||||
<Badge className="text-xs bg-blue-500 hover:bg-blue-600">{count}건</Badge>
|
||||
)}
|
||||
</div>
|
||||
{headerAction}
|
||||
</div>
|
||||
|
||||
{/* 컬럼 컨텐츠 */}
|
||||
<div className="flex-1 p-2 space-y-2 overflow-y-auto min-h-[500px] max-h-[calc(100vh-300px)]">
|
||||
{isEmpty ? (
|
||||
<div className="flex items-center justify-center h-24 text-sm text-muted-foreground">
|
||||
{emptyMessage}
|
||||
</div>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
'use client';
|
||||
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { ProjectDetail, ProjectStatus } from './types';
|
||||
|
||||
interface ProjectCardProps {
|
||||
project: ProjectDetail;
|
||||
isSelected?: boolean;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export default function ProjectCard({ project, isSelected, onClick }: ProjectCardProps) {
|
||||
// 상태 뱃지 색상
|
||||
const getStatusBadge = (status: ProjectStatus, hasUrgentIssue: boolean) => {
|
||||
if (hasUrgentIssue) {
|
||||
return <Badge variant="destructive" className="text-xs">긴급</Badge>;
|
||||
}
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
return <Badge variant="secondary" className="text-xs">완료</Badge>;
|
||||
case 'in_progress':
|
||||
return <Badge className="text-xs bg-blue-500">진행</Badge>;
|
||||
default:
|
||||
return <Badge variant="outline" className="text-xs">{status}</Badge>;
|
||||
}
|
||||
};
|
||||
|
||||
// 금액 포맷
|
||||
const formatAmount = (amount: number) => {
|
||||
return amount.toLocaleString();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'p-3 bg-card rounded-lg border cursor-pointer transition-all hover:shadow-md',
|
||||
isSelected && 'ring-2 ring-primary border-primary'
|
||||
)}
|
||||
onClick={onClick}
|
||||
>
|
||||
{/* 헤더: 현장명 + 상태 뱃지 */}
|
||||
<div className="flex items-start justify-between gap-2 mb-2">
|
||||
<h4 className="text-sm font-medium text-foreground line-clamp-1">
|
||||
{project.siteName}
|
||||
</h4>
|
||||
{getStatusBadge(project.status, project.hasUrgentIssue)}
|
||||
</div>
|
||||
|
||||
{/* 진행률 */}
|
||||
<div className="mb-2">
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground mb-1">
|
||||
<span>진행률</span>
|
||||
<span className="font-medium text-foreground">{project.progressRate}%</span>
|
||||
</div>
|
||||
<div className="w-full h-2 bg-muted rounded-full overflow-hidden">
|
||||
<div
|
||||
className={cn(
|
||||
'h-full rounded-full transition-all',
|
||||
project.status === 'completed' ? 'bg-gray-400' :
|
||||
project.hasUrgentIssue ? 'bg-red-500' : 'bg-blue-500'
|
||||
)}
|
||||
style={{ width: `${project.progressRate}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 정보 */}
|
||||
<div className="space-y-1 text-xs text-muted-foreground">
|
||||
<div className="flex justify-between">
|
||||
<span>{project.partnerName}</span>
|
||||
<span className="font-medium text-foreground">{project.totalLocations}개소</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>계약금</span>
|
||||
<span className="font-medium text-foreground">{formatAmount(project.contractAmount)}원</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>기간</span>
|
||||
<span>{project.startDate.replace(/-/g, '.')} ~ {project.endDate.replace(/-/g, '.')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { FolderKanban, ClipboardList, PlayCircle, CheckCircle2, Search } from 'lucide-react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { PageLayout } from '@/components/organisms/PageLayout';
|
||||
import { PageHeader } from '@/components/organisms/PageHeader';
|
||||
import { DateRangeSelector } from '@/components/molecules/DateRangeSelector';
|
||||
import { toast } from 'sonner';
|
||||
import { format, startOfMonth, endOfMonth } from 'date-fns';
|
||||
import ProjectKanbanBoard from './ProjectKanbanBoard';
|
||||
import ProjectEndDialog from './ProjectEndDialog';
|
||||
import type { ProjectDetail, ProjectStats, SelectOption } from './types';
|
||||
import { getProjectsForKanban, getProjectStats, getPartnerOptions, getSiteOptions } from './actions';
|
||||
|
||||
interface ProjectDetailClientProps {
|
||||
projectId?: string;
|
||||
}
|
||||
|
||||
export default function ProjectDetailClient({ projectId }: ProjectDetailClientProps) {
|
||||
// 데이터 상태
|
||||
const [projects, setProjects] = useState<ProjectDetail[]>([]);
|
||||
const [stats, setStats] = useState<ProjectStats>({ total: 0, inProgress: 0, completed: 0 });
|
||||
const [partnerOptions, setPartnerOptions] = useState<SelectOption[]>([]);
|
||||
const [siteOptions, setSiteOptions] = useState<SelectOption[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
// 필터 상태
|
||||
const [filterStartDate, setFilterStartDate] = useState(() =>
|
||||
format(startOfMonth(new Date()), 'yyyy-MM-dd')
|
||||
);
|
||||
const [filterEndDate, setFilterEndDate] = useState(() =>
|
||||
format(endOfMonth(new Date()), 'yyyy-MM-dd')
|
||||
);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
// 프로젝트 종료 다이얼로그 상태
|
||||
const [endDialogOpen, setEndDialogOpen] = useState(false);
|
||||
const [selectedProject, setSelectedProject] = useState<ProjectDetail | null>(null);
|
||||
|
||||
// 데이터 로드
|
||||
const loadData = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const [projectsResult, statsResult, partnersResult, sitesResult] = await Promise.all([
|
||||
getProjectsForKanban(),
|
||||
getProjectStats(),
|
||||
getPartnerOptions(),
|
||||
getSiteOptions(),
|
||||
]);
|
||||
|
||||
if (projectsResult.success && projectsResult.data) {
|
||||
setProjects(projectsResult.data);
|
||||
}
|
||||
if (statsResult.success && statsResult.data) {
|
||||
setStats(statsResult.data);
|
||||
}
|
||||
if (partnersResult.success && partnersResult.data) {
|
||||
setPartnerOptions(partnersResult.data);
|
||||
}
|
||||
if (sitesResult.success && sitesResult.data) {
|
||||
setSiteOptions(sitesResult.data);
|
||||
}
|
||||
} catch {
|
||||
toast.error('데이터 로드에 실패했습니다.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [loadData]);
|
||||
|
||||
// 검색 필터링된 프로젝트
|
||||
const filteredProjects = projects.filter((project) => {
|
||||
if (!searchQuery) return true;
|
||||
const query = searchQuery.toLowerCase();
|
||||
return (
|
||||
project.siteName.toLowerCase().includes(query) ||
|
||||
project.partnerName.toLowerCase().includes(query) ||
|
||||
project.contractNumber.toLowerCase().includes(query)
|
||||
);
|
||||
});
|
||||
|
||||
// 프로젝트 종료 버튼 클릭 핸들러
|
||||
const handleProjectEndClick = (project: ProjectDetail) => {
|
||||
setSelectedProject(project);
|
||||
setEndDialogOpen(true);
|
||||
};
|
||||
|
||||
// 프로젝트 종료 성공 핸들러
|
||||
const handleEndSuccess = () => {
|
||||
loadData();
|
||||
};
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
{/* 페이지 헤더 */}
|
||||
<PageHeader
|
||||
title="프로젝트 실행 관리"
|
||||
description="프로젝트 실행 관리(제안서)"
|
||||
icon={FolderKanban}
|
||||
/>
|
||||
|
||||
{/* 기간 선택 (달력 + 프리셋 버튼) */}
|
||||
<DateRangeSelector
|
||||
startDate={filterStartDate}
|
||||
endDate={filterEndDate}
|
||||
onStartDateChange={setFilterStartDate}
|
||||
onEndDateChange={setFilterEndDate}
|
||||
/>
|
||||
|
||||
{/* 상태 카드 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-blue-100 rounded-lg">
|
||||
<ClipboardList className="h-5 w-5 text-blue-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">전체 프로젝트</p>
|
||||
<p className="text-2xl font-bold">{stats.total}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-green-100 rounded-lg">
|
||||
<PlayCircle className="h-5 w-5 text-green-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">프로젝트 진행</p>
|
||||
<p className="text-2xl font-bold">{stats.inProgress}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-gray-100 rounded-lg">
|
||||
<CheckCircle2 className="h-5 w-5 text-gray-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">프로젝트 완료</p>
|
||||
<p className="text-2xl font-bold">{stats.completed}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 검색 영역 */}
|
||||
<div className="relative max-w-md">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="프로젝트 검색 (현장명, 거래처, 계약번호)"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 칸반 보드 */}
|
||||
<Card>
|
||||
<CardContent className="p-4 min-h-[600px]">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center h-[500px]">
|
||||
<p className="text-muted-foreground">로딩 중...</p>
|
||||
</div>
|
||||
) : (
|
||||
<ProjectKanbanBoard
|
||||
projects={filteredProjects}
|
||||
partnerOptions={partnerOptions}
|
||||
siteOptions={siteOptions}
|
||||
onProjectEndClick={handleProjectEndClick}
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 프로젝트 종료 다이얼로그 */}
|
||||
<ProjectEndDialog
|
||||
open={endDialogOpen}
|
||||
onOpenChange={setEndDialogOpen}
|
||||
project={selectedProject}
|
||||
onSuccess={handleEndSuccess}
|
||||
/>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { toast } from 'sonner';
|
||||
import type { ProjectDetail, ProjectEndFormData } from './types';
|
||||
import { PROJECT_END_STATUS_OPTIONS } from './types';
|
||||
import { updateProjectEnd } from './actions';
|
||||
|
||||
interface ProjectEndDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
project: ProjectDetail | null;
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
export default function ProjectEndDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
project,
|
||||
onSuccess,
|
||||
}: ProjectEndDialogProps) {
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [formData, setFormData] = useState<ProjectEndFormData>({
|
||||
projectId: '',
|
||||
projectName: '',
|
||||
workDate: '',
|
||||
completionDate: '',
|
||||
status: 'in_progress',
|
||||
memo: '',
|
||||
});
|
||||
|
||||
// 프로젝트가 변경되면 폼 데이터 초기화
|
||||
useEffect(() => {
|
||||
if (project) {
|
||||
setFormData({
|
||||
projectId: project.id,
|
||||
projectName: project.siteName,
|
||||
workDate: project.endDate, // 결선작업일은 프로젝트 종료일로 설정
|
||||
completionDate: new Date().toISOString().split('T')[0], // 오늘 날짜
|
||||
status: project.status === 'completed' ? 'completed' : 'in_progress',
|
||||
memo: '',
|
||||
});
|
||||
}
|
||||
}, [project]);
|
||||
|
||||
// 수정 버튼 클릭
|
||||
const handleSubmit = async () => {
|
||||
if (!project) return;
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const result = await updateProjectEnd(formData);
|
||||
|
||||
if (result.success) {
|
||||
toast.success('프로젝트 종료 처리가 완료되었습니다.');
|
||||
onOpenChange(false);
|
||||
onSuccess?.();
|
||||
} else {
|
||||
toast.error(result.error || '처리 중 오류가 발생했습니다.');
|
||||
}
|
||||
} catch {
|
||||
toast.error('처리 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 삭제 버튼 클릭
|
||||
const handleDelete = () => {
|
||||
toast.info('삭제 기능은 준비 중입니다.');
|
||||
};
|
||||
|
||||
if (!project) return null;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>프로젝트 종료</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid gap-4 py-4">
|
||||
{/* 프로젝트 (현장명) - 읽기전용 */}
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="projectName">프로젝트</Label>
|
||||
<Input
|
||||
id="projectName"
|
||||
value={formData.projectName}
|
||||
disabled
|
||||
className="bg-muted"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 결선작업일 - 읽기전용 */}
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="workDate">결선작업일</Label>
|
||||
<Input
|
||||
id="workDate"
|
||||
type="date"
|
||||
value={formData.workDate}
|
||||
disabled
|
||||
className="bg-muted"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 결선완료일 - 입력 */}
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="completionDate">결선완료일</Label>
|
||||
<Input
|
||||
id="completionDate"
|
||||
type="date"
|
||||
value={formData.completionDate}
|
||||
onChange={(e) =>
|
||||
setFormData((prev) => ({ ...prev, completionDate: e.target.value }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 상태 - 셀렉트 */}
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="status">상태</Label>
|
||||
<Select
|
||||
value={formData.status}
|
||||
onValueChange={(value: 'in_progress' | 'completed') =>
|
||||
setFormData((prev) => ({ ...prev, status: value }))
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="상태 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{PROJECT_END_STATUS_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 메모 - 텍스트에어리어 */}
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="memo">메모</Label>
|
||||
<Textarea
|
||||
id="memo"
|
||||
placeholder="메모를 입력해주세요."
|
||||
value={formData.memo}
|
||||
onChange={(e) =>
|
||||
setFormData((prev) => ({ ...prev, memo: e.target.value }))
|
||||
}
|
||||
className="min-h-[100px]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 버튼 영역 */}
|
||||
<div className="flex justify-center gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleDelete}
|
||||
disabled={isSubmitting}
|
||||
className="w-24"
|
||||
>
|
||||
삭제
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={isSubmitting}
|
||||
className="w-24"
|
||||
>
|
||||
{isSubmitting ? '처리중...' : '수정'}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,366 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo, useRef, useEffect, useState } from 'react';
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { Project, ChartViewMode } from './types';
|
||||
import { GANTT_BAR_COLORS } from './types';
|
||||
|
||||
interface ProjectGanttChartProps {
|
||||
projects: Project[];
|
||||
viewMode: ChartViewMode;
|
||||
currentDate: Date;
|
||||
onProjectClick: (project: Project) => void;
|
||||
onDateChange: (date: Date) => void;
|
||||
}
|
||||
|
||||
export default function ProjectGanttChart({
|
||||
projects,
|
||||
viewMode,
|
||||
currentDate,
|
||||
onProjectClick,
|
||||
onDateChange,
|
||||
}: ProjectGanttChartProps) {
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
const [isScrolling, setIsScrolling] = useState(false);
|
||||
|
||||
// 현재 날짜 기준으로 표시할 기간 계산
|
||||
const { columns, startDate, endDate, yearGroups, monthGroups } = useMemo(() => {
|
||||
const now = currentDate;
|
||||
|
||||
if (viewMode === 'day') {
|
||||
// 일 모드: 현재 월의 1일~말일
|
||||
const year = now.getFullYear();
|
||||
const month = now.getMonth();
|
||||
const daysInMonth = new Date(year, month + 1, 0).getDate();
|
||||
const cols = Array.from({ length: daysInMonth }, (_, i) => ({
|
||||
label: String(i + 1),
|
||||
date: new Date(year, month, i + 1),
|
||||
year,
|
||||
month,
|
||||
}));
|
||||
return {
|
||||
columns: cols,
|
||||
startDate: new Date(year, month, 1),
|
||||
endDate: new Date(year, month, daysInMonth),
|
||||
yearGroups: null,
|
||||
monthGroups: null,
|
||||
};
|
||||
} else if (viewMode === 'week') {
|
||||
// 주 모드: 현재 월 기준 전후 2개월 (총 12주)
|
||||
const year = now.getFullYear();
|
||||
const month = now.getMonth();
|
||||
|
||||
// 전월 1일부터 시작
|
||||
const startMonth = month === 0 ? 11 : month - 1;
|
||||
const startYear = month === 0 ? year - 1 : year;
|
||||
const periodStart = new Date(startYear, startMonth, 1);
|
||||
|
||||
// 다음월 말일까지
|
||||
const endMonth = month === 11 ? 0 : month + 1;
|
||||
const endYear = month === 11 ? year + 1 : year;
|
||||
const periodEnd = new Date(endYear, endMonth + 1, 0);
|
||||
|
||||
// 주차별 컬럼 생성 (월요일 시작)
|
||||
const cols: { label: string; date: Date; year: number; month: number; weekStart: Date; weekEnd: Date }[] = [];
|
||||
const tempDate = new Date(periodStart);
|
||||
|
||||
// 첫 번째 월요일 찾기
|
||||
while (tempDate.getDay() !== 1) {
|
||||
tempDate.setDate(tempDate.getDate() + 1);
|
||||
}
|
||||
|
||||
let weekNum = 1;
|
||||
while (tempDate <= periodEnd) {
|
||||
const weekStart = new Date(tempDate);
|
||||
const weekEnd = new Date(tempDate);
|
||||
weekEnd.setDate(weekEnd.getDate() + 6);
|
||||
|
||||
cols.push({
|
||||
label: `${weekNum}주`,
|
||||
date: new Date(tempDate),
|
||||
year: tempDate.getFullYear(),
|
||||
month: tempDate.getMonth(),
|
||||
weekStart,
|
||||
weekEnd,
|
||||
});
|
||||
|
||||
tempDate.setDate(tempDate.getDate() + 7);
|
||||
weekNum++;
|
||||
}
|
||||
|
||||
// 월별 그룹 계산
|
||||
const monthGroupsMap = new Map<string, number>();
|
||||
cols.forEach((col) => {
|
||||
const key = `${col.year}-${col.month}`;
|
||||
monthGroupsMap.set(key, (monthGroupsMap.get(key) || 0) + 1);
|
||||
});
|
||||
|
||||
const mGroups = Array.from(monthGroupsMap.entries()).map(([key, count]) => {
|
||||
const [y, m] = key.split('-').map(Number);
|
||||
return { year: y, month: m, count, label: `${m + 1}월` };
|
||||
});
|
||||
|
||||
return {
|
||||
columns: cols,
|
||||
startDate: cols[0]?.weekStart || periodStart,
|
||||
endDate: cols[cols.length - 1]?.weekEnd || periodEnd,
|
||||
yearGroups: null,
|
||||
monthGroups: mGroups,
|
||||
};
|
||||
} else {
|
||||
// 월 모드: 전년도 + 올해 (2년치, 24개월)
|
||||
const year = now.getFullYear();
|
||||
const prevYear = year - 1;
|
||||
const cols: { label: string; date: Date; year: number; month: number }[] = [];
|
||||
|
||||
// 전년도 12개월
|
||||
for (let i = 0; i < 12; i++) {
|
||||
cols.push({
|
||||
label: `${i + 1}월`,
|
||||
date: new Date(prevYear, i, 1),
|
||||
year: prevYear,
|
||||
month: i,
|
||||
});
|
||||
}
|
||||
// 올해 12개월
|
||||
for (let i = 0; i < 12; i++) {
|
||||
cols.push({
|
||||
label: `${i + 1}월`,
|
||||
date: new Date(year, i, 1),
|
||||
year: year,
|
||||
month: i,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
columns: cols,
|
||||
startDate: new Date(prevYear, 0, 1),
|
||||
endDate: new Date(year, 11, 31),
|
||||
yearGroups: [
|
||||
{ year: prevYear, count: 12 },
|
||||
{ year: year, count: 12 },
|
||||
],
|
||||
monthGroups: null,
|
||||
};
|
||||
}
|
||||
}, [viewMode, currentDate]);
|
||||
|
||||
// 막대 위치 및 너비 계산
|
||||
const getBarStyle = (project: Project) => {
|
||||
const projectStart = new Date(project.startDate);
|
||||
const projectEnd = new Date(project.endDate);
|
||||
|
||||
// 범위 밖이면 표시 안함
|
||||
if (projectEnd < startDate || projectStart > endDate) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 시작/종료 위치 계산
|
||||
const totalDays = (endDate.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24);
|
||||
const barStartDays = Math.max(0, (projectStart.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24));
|
||||
const barEndDays = Math.min(totalDays, (projectEnd.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24));
|
||||
|
||||
const leftPercent = (barStartDays / totalDays) * 100;
|
||||
const widthPercent = ((barEndDays - barStartDays) / totalDays) * 100;
|
||||
|
||||
// 색상 결정
|
||||
let bgColor = GANTT_BAR_COLORS.in_progress;
|
||||
if (project.status === 'completed') {
|
||||
bgColor = GANTT_BAR_COLORS.completed;
|
||||
} else if (project.hasUrgentIssue || project.status === 'urgent') {
|
||||
bgColor = GANTT_BAR_COLORS.urgent;
|
||||
}
|
||||
|
||||
return {
|
||||
left: `${leftPercent}%`,
|
||||
width: `${Math.max(widthPercent, 1)}%`,
|
||||
backgroundColor: bgColor,
|
||||
};
|
||||
};
|
||||
|
||||
// 이전/다음 이동
|
||||
const handlePrev = () => {
|
||||
const newDate = new Date(currentDate);
|
||||
if (viewMode === 'day') {
|
||||
newDate.setMonth(newDate.getMonth() - 1);
|
||||
} else if (viewMode === 'week') {
|
||||
newDate.setMonth(newDate.getMonth() - 1);
|
||||
} else {
|
||||
newDate.setFullYear(newDate.getFullYear() - 1);
|
||||
}
|
||||
onDateChange(newDate);
|
||||
};
|
||||
|
||||
const handleNext = () => {
|
||||
const newDate = new Date(currentDate);
|
||||
if (viewMode === 'day') {
|
||||
newDate.setMonth(newDate.getMonth() + 1);
|
||||
} else if (viewMode === 'week') {
|
||||
newDate.setMonth(newDate.getMonth() + 1);
|
||||
} else {
|
||||
newDate.setFullYear(newDate.getFullYear() + 1);
|
||||
}
|
||||
onDateChange(newDate);
|
||||
};
|
||||
|
||||
// 월 모드에서 올해 시작 위치로 스크롤
|
||||
useEffect(() => {
|
||||
if (scrollContainerRef.current && viewMode === 'month') {
|
||||
// 올해 1월 위치로 스크롤 (전년도 12개월 건너뛰기)
|
||||
const totalWidth = scrollContainerRef.current.scrollWidth;
|
||||
const scrollPosition = totalWidth / 2 - scrollContainerRef.current.clientWidth / 3;
|
||||
scrollContainerRef.current.scrollLeft = Math.max(0, scrollPosition);
|
||||
} else if (scrollContainerRef.current && viewMode === 'day') {
|
||||
const today = new Date();
|
||||
const dayOfMonth = today.getDate();
|
||||
const columnWidth = scrollContainerRef.current.scrollWidth / columns.length;
|
||||
const scrollPosition = (dayOfMonth - 1) * columnWidth - scrollContainerRef.current.clientWidth / 2;
|
||||
scrollContainerRef.current.scrollLeft = Math.max(0, scrollPosition);
|
||||
}
|
||||
}, [viewMode, columns.length]);
|
||||
|
||||
return (
|
||||
<div className="border rounded-lg bg-card">
|
||||
{/* 헤더: 날짜 네비게이션 */}
|
||||
<div className="flex items-center justify-between p-3 border-b">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="icon" onClick={handlePrev}>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<span className="text-sm font-medium min-w-[140px] text-center">
|
||||
{viewMode === 'day'
|
||||
? `${currentDate.getFullYear()}년 ${currentDate.getMonth() + 1}월`
|
||||
: viewMode === 'week'
|
||||
? `${currentDate.getFullYear()}년 ${currentDate.getMonth()}월 ~ ${currentDate.getMonth() + 2}월`
|
||||
: `${currentDate.getFullYear() - 1}년 ~ ${currentDate.getFullYear()}년`}
|
||||
</span>
|
||||
<Button variant="outline" size="icon" onClick={handleNext}>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 범례 */}
|
||||
<div className="flex items-center gap-4 text-xs">
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="w-3 h-3 rounded" style={{ backgroundColor: GANTT_BAR_COLORS.in_progress }} />
|
||||
<span>진행중</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="w-3 h-3 rounded" style={{ backgroundColor: GANTT_BAR_COLORS.completed }} />
|
||||
<span>종료</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="w-3 h-3 rounded" style={{ backgroundColor: GANTT_BAR_COLORS.urgent }} />
|
||||
<span>긴급 이슈</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 차트 영역 */}
|
||||
<div className="overflow-hidden">
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
className="overflow-x-auto"
|
||||
onMouseDown={() => setIsScrolling(true)}
|
||||
onMouseUp={() => setIsScrolling(false)}
|
||||
onMouseLeave={() => setIsScrolling(false)}
|
||||
>
|
||||
<div className={cn(
|
||||
viewMode === 'month' ? 'min-w-[1600px]' : viewMode === 'week' ? 'min-w-[1000px]' : 'min-w-[800px]'
|
||||
)}>
|
||||
{/* 전체를 하나의 세로 스크롤 영역으로 */}
|
||||
<div className="max-h-[450px] overflow-y-auto">
|
||||
{/* 연도 헤더 (월 모드에서만) */}
|
||||
{viewMode === 'month' && yearGroups && (
|
||||
<div className="flex bg-muted/50 sticky top-0 z-20">
|
||||
{yearGroups.map((group) => (
|
||||
<div
|
||||
key={group.year}
|
||||
className="flex-1 p-1.5 text-xs font-semibold text-center border-r border-border last:border-r-0"
|
||||
style={{ flex: group.count }}
|
||||
>
|
||||
{group.year}년
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 월 헤더 (주 모드에서만) */}
|
||||
{viewMode === 'week' && monthGroups && (
|
||||
<div className="flex bg-muted/50 sticky top-0 z-20">
|
||||
{monthGroups.map((group, idx) => (
|
||||
<div
|
||||
key={`${group.year}-${group.month}-${idx}`}
|
||||
className="flex-1 p-1.5 text-xs font-semibold text-center border-r border-border last:border-r-0"
|
||||
style={{ flex: group.count }}
|
||||
>
|
||||
{group.label}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 컬럼 헤더 - 날짜/주/월 */}
|
||||
<div className={cn(
|
||||
'flex bg-muted/30 sticky z-10',
|
||||
(viewMode === 'month' || viewMode === 'week') ? 'top-[30px]' : 'top-0'
|
||||
)}>
|
||||
{columns.map((col, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className={cn(
|
||||
'flex-1 p-2 text-xs text-center border-r border-border last:border-r-0',
|
||||
viewMode === 'day' && col.date.getDay() === 0 && 'text-red-500',
|
||||
viewMode === 'day' && col.date.getDay() === 6 && 'text-blue-500'
|
||||
)}
|
||||
>
|
||||
{col.label}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 프로젝트 행들 (가로선 없음) */}
|
||||
{projects.length === 0 ? (
|
||||
<div className="p-8 text-center text-muted-foreground">
|
||||
표시할 프로젝트가 없습니다.
|
||||
</div>
|
||||
) : (
|
||||
projects.map((project) => {
|
||||
const barStyle = getBarStyle(project);
|
||||
return (
|
||||
<div
|
||||
key={project.id}
|
||||
className="relative h-12 hover:bg-muted/10 cursor-pointer"
|
||||
onClick={() => !isScrolling && onProjectClick(project)}
|
||||
>
|
||||
{/* 그리드 세로선 */}
|
||||
<div className="absolute inset-0 flex">
|
||||
{columns.map((_, idx) => (
|
||||
<div key={idx} className="flex-1 border-r border-border last:border-r-0" />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 막대 - 프로젝트명 직접 표시 */}
|
||||
{barStyle && (
|
||||
<div
|
||||
className="absolute top-1/2 -translate-y-1/2 h-7 rounded text-xs text-white flex items-center px-2 truncate shadow-sm"
|
||||
style={barStyle}
|
||||
>
|
||||
<span className="truncate font-medium">
|
||||
[{project.partnerName}] {project.siteName} {project.progressRate}%
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useMemo, useEffect } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import KanbanColumn from './KanbanColumn';
|
||||
import ProjectCard from './ProjectCard';
|
||||
import StageCard from './StageCard';
|
||||
import DetailAccordion from './DetailAccordion';
|
||||
import type { ProjectDetail, Stage, DetailCategory, SelectOption } from './types';
|
||||
import { STAGE_LABELS } from './types';
|
||||
import { getDetailCategories } from './actions';
|
||||
|
||||
interface ProjectKanbanBoardProps {
|
||||
projects: ProjectDetail[];
|
||||
partnerOptions?: SelectOption[];
|
||||
siteOptions?: SelectOption[];
|
||||
onProjectEndClick?: (project: ProjectDetail) => void;
|
||||
}
|
||||
|
||||
export default function ProjectKanbanBoard({
|
||||
projects,
|
||||
partnerOptions = [],
|
||||
siteOptions = [],
|
||||
onProjectEndClick,
|
||||
}: ProjectKanbanBoardProps) {
|
||||
// 필터 상태
|
||||
const [selectedPartner, setSelectedPartner] = useState<string>('all');
|
||||
const [selectedSite, setSelectedSite] = useState<string>('all');
|
||||
|
||||
// 선택된 프로젝트
|
||||
const [selectedProjectId, setSelectedProjectId] = useState<string | null>(null);
|
||||
// 선택된 단계
|
||||
const [selectedStageId, setSelectedStageId] = useState<string | null>(null);
|
||||
// 상세 카테고리 (시공, 이슈 아코디언)
|
||||
const [detailCategories, setDetailCategories] = useState<DetailCategory[]>([]);
|
||||
const [isLoadingCategories, setIsLoadingCategories] = useState(false);
|
||||
// 선택된 상세 아이템
|
||||
const [selectedDetailId, setSelectedDetailId] = useState<string | null>(null);
|
||||
|
||||
// 필터링된 프로젝트
|
||||
const filteredProjects = useMemo(() => {
|
||||
let result = [...projects];
|
||||
|
||||
if (selectedPartner !== 'all') {
|
||||
result = result.filter((p) => p.partnerName === selectedPartner);
|
||||
}
|
||||
|
||||
if (selectedSite !== 'all') {
|
||||
result = result.filter((p) => p.siteName === selectedSite);
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [projects, selectedPartner, selectedSite]);
|
||||
|
||||
// 선택된 프로젝트 정보
|
||||
const selectedProject = useMemo(() => {
|
||||
return filteredProjects.find((p) => p.id === selectedProjectId) || null;
|
||||
}, [filteredProjects, selectedProjectId]);
|
||||
|
||||
// 선택된 프로젝트의 단계 목록
|
||||
const stages = useMemo(() => {
|
||||
if (!selectedProject) return [];
|
||||
return selectedProject.stages || [];
|
||||
}, [selectedProject]);
|
||||
|
||||
// 선택된 단계 정보
|
||||
const selectedStage = useMemo(() => {
|
||||
return stages.find((s) => s.id === selectedStageId) || null;
|
||||
}, [stages, selectedStageId]);
|
||||
|
||||
// 단계 선택 시 상세 카테고리 로드
|
||||
useEffect(() => {
|
||||
if (!selectedStageId) {
|
||||
setDetailCategories([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const loadCategories = async () => {
|
||||
setIsLoadingCategories(true);
|
||||
try {
|
||||
const result = await getDetailCategories(selectedStageId);
|
||||
if (result.success && result.data) {
|
||||
setDetailCategories(result.data);
|
||||
} else {
|
||||
setDetailCategories([]);
|
||||
}
|
||||
} catch {
|
||||
setDetailCategories([]);
|
||||
} finally {
|
||||
setIsLoadingCategories(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadCategories();
|
||||
}, [selectedStageId]);
|
||||
|
||||
// 프로젝트 선택 핸들러
|
||||
const handleProjectClick = (project: ProjectDetail) => {
|
||||
if (selectedProjectId === project.id) {
|
||||
// 이미 선택된 프로젝트 클릭 시 선택 해제
|
||||
setSelectedProjectId(null);
|
||||
setSelectedStageId(null);
|
||||
} else {
|
||||
setSelectedProjectId(project.id);
|
||||
setSelectedStageId(null);
|
||||
}
|
||||
};
|
||||
|
||||
// 단계 선택 핸들러
|
||||
const handleStageClick = (stage: Stage) => {
|
||||
if (selectedStageId === stage.id) {
|
||||
setSelectedStageId(null);
|
||||
} else {
|
||||
setSelectedStageId(stage.id);
|
||||
}
|
||||
// 단계 변경 시 상세 선택 초기화
|
||||
setSelectedDetailId(null);
|
||||
};
|
||||
|
||||
// 상세 아이템 선택 핸들러
|
||||
const handleDetailSelect = (id: string) => {
|
||||
if (selectedDetailId === id) {
|
||||
setSelectedDetailId(null);
|
||||
} else {
|
||||
setSelectedDetailId(id);
|
||||
}
|
||||
};
|
||||
|
||||
// 프로젝트 종료 버튼 클릭 핸들러
|
||||
const handleProjectEndClick = () => {
|
||||
if (selectedProject && onProjectEndClick) {
|
||||
onProjectEndClick(selectedProject);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 필터 영역 */}
|
||||
<div className="flex items-center justify-end gap-3">
|
||||
<Select value={selectedPartner} onValueChange={setSelectedPartner}>
|
||||
<SelectTrigger className="w-[140px]">
|
||||
<SelectValue placeholder="전체" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">전체</SelectItem>
|
||||
{partnerOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select value={selectedSite} onValueChange={setSelectedSite}>
|
||||
<SelectTrigger className="w-[140px]">
|
||||
<SelectValue placeholder="전체" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">전체</SelectItem>
|
||||
{siteOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 칸반 보드 */}
|
||||
<div className="flex gap-4 w-full">
|
||||
{/* 프로젝트 컬럼 */}
|
||||
<KanbanColumn
|
||||
title="프로젝트"
|
||||
count={filteredProjects.length}
|
||||
isEmpty={filteredProjects.length === 0}
|
||||
emptyMessage="프로젝트가 없습니다."
|
||||
>
|
||||
{filteredProjects.map((project) => (
|
||||
<ProjectCard
|
||||
key={project.id}
|
||||
project={project}
|
||||
isSelected={selectedProjectId === project.id}
|
||||
onClick={() => handleProjectClick(project)}
|
||||
/>
|
||||
))}
|
||||
</KanbanColumn>
|
||||
|
||||
{/* 단계 컬럼 */}
|
||||
<KanbanColumn
|
||||
title="단계"
|
||||
headerAction={
|
||||
selectedProject && (
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
className="h-7 text-xs"
|
||||
onClick={handleProjectEndClick}
|
||||
>
|
||||
프로젝트 종료
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
isEmpty={stages.length === 0}
|
||||
emptyMessage={selectedProjectId ? '단계가 없습니다.' : '프로젝트를 선택하세요.'}
|
||||
>
|
||||
{stages.map((stage) => (
|
||||
<StageCard
|
||||
key={stage.id}
|
||||
stage={stage}
|
||||
isSelected={selectedStageId === stage.id}
|
||||
onClick={() => handleStageClick(stage)}
|
||||
/>
|
||||
))}
|
||||
</KanbanColumn>
|
||||
|
||||
{/* 상세 컬럼 */}
|
||||
<KanbanColumn
|
||||
title="상세"
|
||||
isEmpty={!selectedStageId && detailCategories.length === 0}
|
||||
emptyMessage={selectedStageId ? '상세 항목이 없습니다.' : '단계를 선택하세요.'}
|
||||
>
|
||||
{isLoadingCategories ? (
|
||||
<div className="flex items-center justify-center h-24 text-sm text-muted-foreground">
|
||||
로딩 중...
|
||||
</div>
|
||||
) : (
|
||||
<DetailAccordion
|
||||
categories={detailCategories}
|
||||
selectedDetailId={selectedDetailId}
|
||||
onDetailSelect={handleDetailSelect}
|
||||
/>
|
||||
)}
|
||||
</KanbanColumn>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,661 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useMemo, useCallback, useEffect, Fragment } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { FolderKanban, Pencil, ClipboardList, PlayCircle, CheckCircle2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { PageLayout } from '@/components/organisms/PageLayout';
|
||||
import { PageHeader } from '@/components/organisms/PageHeader';
|
||||
import { MobileCard } from '@/components/molecules/MobileCard';
|
||||
import { toast } from 'sonner';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { Project, ProjectStats, ChartViewMode, SelectOption } from './types';
|
||||
import { STATUS_OPTIONS, SORT_OPTIONS } from './types';
|
||||
import { DateRangeSelector } from '@/components/molecules/DateRangeSelector';
|
||||
import { format, startOfMonth, endOfMonth } from 'date-fns';
|
||||
import {
|
||||
getProjectList,
|
||||
getProjectStats,
|
||||
getPartnerOptions,
|
||||
getSiteOptions,
|
||||
getContractManagerOptions,
|
||||
getConstructionPMOptions,
|
||||
} from './actions';
|
||||
import ProjectGanttChart from './ProjectGanttChart';
|
||||
|
||||
// 다중 선택 셀렉트 컴포넌트
|
||||
function MultiSelectFilter({
|
||||
label,
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
options: SelectOption[];
|
||||
value: string[];
|
||||
onChange: (value: string[]) => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const handleToggle = (optionValue: string) => {
|
||||
if (optionValue === 'all') {
|
||||
onChange(['all']);
|
||||
} else {
|
||||
const newValue = value.includes(optionValue)
|
||||
? value.filter((v) => v !== optionValue && v !== 'all')
|
||||
: [...value.filter((v) => v !== 'all'), optionValue];
|
||||
onChange(newValue.length === 0 ? ['all'] : newValue);
|
||||
}
|
||||
};
|
||||
|
||||
const displayValue = value.includes('all') || value.length === 0
|
||||
? '전체'
|
||||
: value.length === 1
|
||||
? options.find((o) => o.value === value[0])?.label || value[0]
|
||||
: `${value.length}개 선택`;
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-[140px] justify-between text-left font-normal"
|
||||
onClick={() => setOpen(!open)}
|
||||
>
|
||||
<span className="truncate">{displayValue}</span>
|
||||
</Button>
|
||||
{open && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-40" onClick={() => setOpen(false)} />
|
||||
<div className="absolute top-full left-0 z-50 mt-1 w-[200px] rounded-md border bg-popover p-1 shadow-md">
|
||||
<div
|
||||
className="flex items-center gap-2 p-2 hover:bg-muted rounded cursor-pointer"
|
||||
onClick={() => handleToggle('all')}
|
||||
>
|
||||
<Checkbox checked={value.includes('all') || value.length === 0} />
|
||||
<span className="text-sm">전체</span>
|
||||
</div>
|
||||
{options.map((option) => (
|
||||
<div
|
||||
key={option.value}
|
||||
className="flex items-center gap-2 p-2 hover:bg-muted rounded cursor-pointer"
|
||||
onClick={() => handleToggle(option.value)}
|
||||
>
|
||||
<Checkbox checked={value.includes(option.value)} />
|
||||
<span className="text-sm">{option.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ProjectListClientProps {
|
||||
initialData?: Project[];
|
||||
initialStats?: ProjectStats;
|
||||
}
|
||||
|
||||
export default function ProjectListClient({ initialData = [], initialStats }: ProjectListClientProps) {
|
||||
const router = useRouter();
|
||||
|
||||
// 상태
|
||||
const [projects, setProjects] = useState<Project[]>(initialData);
|
||||
const [stats, setStats] = useState<ProjectStats>(
|
||||
initialStats ?? { total: 0, inProgress: 0, completed: 0 }
|
||||
);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
// 날짜 범위 (기간 선택)
|
||||
const [filterStartDate, setFilterStartDate] = useState(() => format(startOfMonth(new Date()), 'yyyy-MM-dd'));
|
||||
const [filterEndDate, setFilterEndDate] = useState(() => format(endOfMonth(new Date()), 'yyyy-MM-dd'));
|
||||
|
||||
// 간트차트 상태
|
||||
const [chartViewMode, setChartViewMode] = useState<ChartViewMode>('day');
|
||||
// TODO: 실제 API 연동 시 new Date()로 변경 (현재 목업 데이터가 2025년이라 임시 설정)
|
||||
const [chartDate, setChartDate] = useState(new Date(2025, 0, 15));
|
||||
const [chartPartnerFilter, setChartPartnerFilter] = useState<string[]>(['all']);
|
||||
const [chartSiteFilter, setChartSiteFilter] = useState<string[]>(['all']);
|
||||
|
||||
// 테이블 필터
|
||||
const [partnerFilter, setPartnerFilter] = useState<string[]>(['all']);
|
||||
const [contractManagerFilter, setContractManagerFilter] = useState<string[]>(['all']);
|
||||
const [pmFilter, setPmFilter] = useState<string[]>(['all']);
|
||||
const [statusFilter, setStatusFilter] = useState('all');
|
||||
const [sortBy, setSortBy] = useState<'latest' | 'progress' | 'register' | 'completion'>('latest');
|
||||
|
||||
// 필터 옵션들
|
||||
const [partnerOptions, setPartnerOptions] = useState<SelectOption[]>([]);
|
||||
const [siteOptions, setSiteOptions] = useState<SelectOption[]>([]);
|
||||
const [contractManagerOptions, setContractManagerOptions] = useState<SelectOption[]>([]);
|
||||
const [pmOptions, setPmOptions] = useState<SelectOption[]>([]);
|
||||
|
||||
// 테이블 상태
|
||||
const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set());
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const itemsPerPage = 20;
|
||||
|
||||
// 데이터 로드
|
||||
const loadData = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const [listResult, statsResult, partners, sites, managers, pms] = await Promise.all([
|
||||
getProjectList({
|
||||
partners: partnerFilter.includes('all') ? undefined : partnerFilter,
|
||||
contractManagers: contractManagerFilter.includes('all') ? undefined : contractManagerFilter,
|
||||
constructionPMs: pmFilter.includes('all') ? undefined : pmFilter,
|
||||
status: statusFilter === 'all' ? undefined : statusFilter,
|
||||
sortBy,
|
||||
size: 1000,
|
||||
}),
|
||||
getProjectStats(),
|
||||
getPartnerOptions(),
|
||||
getSiteOptions(),
|
||||
getContractManagerOptions(),
|
||||
getConstructionPMOptions(),
|
||||
]);
|
||||
|
||||
if (listResult.success && listResult.data) {
|
||||
setProjects(listResult.data.items);
|
||||
}
|
||||
if (statsResult.success && statsResult.data) {
|
||||
setStats(statsResult.data);
|
||||
}
|
||||
if (partners.success && partners.data) {
|
||||
setPartnerOptions(partners.data);
|
||||
}
|
||||
if (sites.success && sites.data) {
|
||||
setSiteOptions(sites.data);
|
||||
}
|
||||
if (managers.success && managers.data) {
|
||||
setContractManagerOptions(managers.data);
|
||||
}
|
||||
if (pms.success && pms.data) {
|
||||
setPmOptions(pms.data);
|
||||
}
|
||||
} catch {
|
||||
toast.error('데이터 로드에 실패했습니다.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [partnerFilter, contractManagerFilter, pmFilter, statusFilter, sortBy]);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [loadData]);
|
||||
|
||||
// 간트차트용 필터링된 프로젝트
|
||||
const chartProjects = useMemo(() => {
|
||||
return projects.filter((project) => {
|
||||
if (!chartPartnerFilter.includes('all') && !chartPartnerFilter.includes(project.partnerName)) {
|
||||
return false;
|
||||
}
|
||||
if (!chartSiteFilter.includes('all') && !chartSiteFilter.includes(project.siteName)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}, [projects, chartPartnerFilter, chartSiteFilter]);
|
||||
|
||||
// 페이지네이션
|
||||
const totalPages = Math.ceil(projects.length / itemsPerPage);
|
||||
const paginatedData = useMemo(() => {
|
||||
const start = (currentPage - 1) * itemsPerPage;
|
||||
return projects.slice(start, start + itemsPerPage);
|
||||
}, [projects, currentPage, itemsPerPage]);
|
||||
|
||||
const startIndex = (currentPage - 1) * itemsPerPage;
|
||||
|
||||
// 핸들러
|
||||
const handleToggleSelection = useCallback((id: string) => {
|
||||
setSelectedItems((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(id)) {
|
||||
newSet.delete(id);
|
||||
} else {
|
||||
newSet.add(id);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleToggleSelectAll = useCallback(() => {
|
||||
if (selectedItems.size === paginatedData.length) {
|
||||
setSelectedItems(new Set());
|
||||
} else {
|
||||
setSelectedItems(new Set(paginatedData.map((p) => p.id)));
|
||||
}
|
||||
}, [selectedItems.size, paginatedData]);
|
||||
|
||||
const handleRowClick = useCallback(
|
||||
(project: Project) => {
|
||||
router.push(`/ko/construction/project/management/${project.id}`);
|
||||
},
|
||||
[router]
|
||||
);
|
||||
|
||||
const handleEdit = useCallback(
|
||||
(e: React.MouseEvent, projectId: string) => {
|
||||
e.stopPropagation();
|
||||
router.push(`/ko/construction/project/management/${projectId}/edit`);
|
||||
},
|
||||
[router]
|
||||
);
|
||||
|
||||
const handleGanttProjectClick = useCallback(
|
||||
(project: Project) => {
|
||||
router.push(`/ko/construction/project/management/${project.id}`);
|
||||
},
|
||||
[router]
|
||||
);
|
||||
|
||||
// 금액 포맷
|
||||
const formatAmount = (amount: number) => {
|
||||
return amount.toLocaleString() + '원';
|
||||
};
|
||||
|
||||
// 날짜 포맷
|
||||
const formatDate = (dateStr: string) => {
|
||||
return dateStr.replace(/-/g, '.');
|
||||
};
|
||||
|
||||
// 상태 뱃지
|
||||
const getStatusBadge = (status: string, hasUrgentIssue: boolean) => {
|
||||
if (hasUrgentIssue) {
|
||||
return <Badge variant="destructive">긴급</Badge>;
|
||||
}
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
return <Badge variant="secondary">완료</Badge>;
|
||||
case 'in_progress':
|
||||
return <Badge variant="default">진행중</Badge>;
|
||||
default:
|
||||
return <Badge variant="outline">{status}</Badge>;
|
||||
}
|
||||
};
|
||||
|
||||
const allSelected = selectedItems.size === paginatedData.length && paginatedData.length > 0;
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
{/* 페이지 헤더 */}
|
||||
<PageHeader
|
||||
title="프로젝트 관리"
|
||||
description="계약 완료 시 자동 등록된 프로젝트를 관리합니다"
|
||||
icon={FolderKanban}
|
||||
/>
|
||||
|
||||
{/* 기간 선택 (달력 + 프리셋 버튼) */}
|
||||
<DateRangeSelector
|
||||
startDate={filterStartDate}
|
||||
endDate={filterEndDate}
|
||||
onStartDateChange={setFilterStartDate}
|
||||
onEndDateChange={setFilterEndDate}
|
||||
/>
|
||||
|
||||
{/* 상태 카드 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-blue-100 rounded-lg">
|
||||
<ClipboardList className="h-5 w-5 text-blue-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">전체 프로젝트</p>
|
||||
<p className="text-2xl font-bold">{stats.total}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-green-100 rounded-lg">
|
||||
<PlayCircle className="h-5 w-5 text-green-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">프로젝트 진행</p>
|
||||
<p className="text-2xl font-bold">{stats.inProgress}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-gray-100 rounded-lg">
|
||||
<CheckCircle2 className="h-5 w-5 text-gray-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">프로젝트 완료</p>
|
||||
<p className="text-2xl font-bold">{stats.completed}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 프로젝트 일정 간트차트 */}
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* 간트차트 상단 컨트롤 */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium">프로젝트 일정</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
{/* 일/주/월 전환 */}
|
||||
<div className="flex border rounded-md">
|
||||
<Button
|
||||
variant={chartViewMode === 'day' ? 'default' : 'ghost'}
|
||||
size="sm"
|
||||
className="rounded-r-none border-r-0"
|
||||
onClick={() => setChartViewMode('day')}
|
||||
>
|
||||
일
|
||||
</Button>
|
||||
<Button
|
||||
variant={chartViewMode === 'week' ? 'default' : 'ghost'}
|
||||
size="sm"
|
||||
className="rounded-none border-r-0"
|
||||
onClick={() => setChartViewMode('week')}
|
||||
>
|
||||
주
|
||||
</Button>
|
||||
<Button
|
||||
variant={chartViewMode === 'month' ? 'default' : 'ghost'}
|
||||
size="sm"
|
||||
className="rounded-l-none"
|
||||
onClick={() => setChartViewMode('month')}
|
||||
>
|
||||
월
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 거래처 필터 */}
|
||||
<MultiSelectFilter
|
||||
label="거래처"
|
||||
options={partnerOptions}
|
||||
value={chartPartnerFilter}
|
||||
onChange={setChartPartnerFilter}
|
||||
/>
|
||||
|
||||
{/* 현장 필터 */}
|
||||
<MultiSelectFilter
|
||||
label="현장"
|
||||
options={siteOptions}
|
||||
value={chartSiteFilter}
|
||||
onChange={setChartSiteFilter}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 간트차트 */}
|
||||
<ProjectGanttChart
|
||||
projects={chartProjects}
|
||||
viewMode={chartViewMode}
|
||||
currentDate={chartDate}
|
||||
onProjectClick={handleGanttProjectClick}
|
||||
onDateChange={setChartDate}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 테이블 영역 */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
{/* 테이블 헤더 (필터들) */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 mb-4">
|
||||
<span className="text-sm text-muted-foreground whitespace-nowrap">
|
||||
총 {projects.length}건
|
||||
</span>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
{/* 거래처 필터 */}
|
||||
<MultiSelectFilter
|
||||
label="거래처"
|
||||
options={partnerOptions}
|
||||
value={partnerFilter}
|
||||
onChange={(v) => {
|
||||
setPartnerFilter(v);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 계약담당자 필터 */}
|
||||
<MultiSelectFilter
|
||||
label="계약담당자"
|
||||
options={contractManagerOptions}
|
||||
value={contractManagerFilter}
|
||||
onChange={(v) => {
|
||||
setContractManagerFilter(v);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 공사PM 필터 */}
|
||||
<MultiSelectFilter
|
||||
label="공사PM"
|
||||
options={pmOptions}
|
||||
value={pmFilter}
|
||||
onChange={(v) => {
|
||||
setPmFilter(v);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 상태 필터 */}
|
||||
<Select value={statusFilter} onValueChange={(v) => { setStatusFilter(v); setCurrentPage(1); }}>
|
||||
<SelectTrigger className="w-[100px]">
|
||||
<SelectValue placeholder="전체" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{STATUS_OPTIONS.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* 정렬 */}
|
||||
<Select value={sortBy} onValueChange={(v) => setSortBy(v as typeof sortBy)}>
|
||||
<SelectTrigger className="w-[100px]">
|
||||
<SelectValue placeholder="최신순" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SORT_OPTIONS.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 데스크톱 테이블 */}
|
||||
<div className="hidden xl:block rounded-md border overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="h-14">
|
||||
<TableHead className="w-[50px] text-center">
|
||||
<Checkbox
|
||||
checked={allSelected}
|
||||
onCheckedChange={handleToggleSelectAll}
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead className="w-[60px] text-center">번호</TableHead>
|
||||
<TableHead className="w-[100px]">계약번호</TableHead>
|
||||
<TableHead className="w-[120px]">거래처</TableHead>
|
||||
<TableHead className="min-w-[150px]">현장명</TableHead>
|
||||
<TableHead className="w-[100px]">계약담당자</TableHead>
|
||||
<TableHead className="w-[100px]">공사PM</TableHead>
|
||||
<TableHead className="w-[80px] text-center">총 개소</TableHead>
|
||||
<TableHead className="w-[120px] text-right">계약금액</TableHead>
|
||||
<TableHead className="w-[80px] text-center">진행률</TableHead>
|
||||
<TableHead className="w-[120px] text-right">누계 기성</TableHead>
|
||||
<TableHead className="w-[180px] text-center">프로젝트 기간</TableHead>
|
||||
<TableHead className="w-[80px] text-center">상태</TableHead>
|
||||
<TableHead className="w-[80px] text-center">작업</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody className="[&_tr]:h-14 [&_tr]:min-h-[56px] [&_tr]:max-h-[56px]">
|
||||
{paginatedData.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={14} className="h-24 text-center">
|
||||
검색 결과가 없습니다.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
paginatedData.map((project, index) => {
|
||||
const isSelected = selectedItems.has(project.id);
|
||||
const globalIndex = startIndex + index + 1;
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
key={project.id}
|
||||
className="cursor-pointer hover:bg-muted/50"
|
||||
onClick={() => handleRowClick(project)}
|
||||
>
|
||||
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={() => handleToggleSelection(project.id)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-center text-muted-foreground">{globalIndex}</TableCell>
|
||||
<TableCell className="font-medium">{project.contractNumber}</TableCell>
|
||||
<TableCell>{project.partnerName}</TableCell>
|
||||
<TableCell>{project.siteName}</TableCell>
|
||||
<TableCell>{project.contractManager}</TableCell>
|
||||
<TableCell>{project.constructionPM}</TableCell>
|
||||
<TableCell className="text-center">{project.totalLocations}</TableCell>
|
||||
<TableCell className="text-right">{formatAmount(project.contractAmount)}</TableCell>
|
||||
<TableCell className="text-center">{project.progressRate}%</TableCell>
|
||||
<TableCell className="text-right">{formatAmount(project.accumulatedPayment)}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{formatDate(project.startDate)} ~ {formatDate(project.endDate)}
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{getStatusBadge(project.status, project.hasUrgentIssue)}
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{isSelected && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={(e) => handleEdit(e, project.id)}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* 모바일/태블릿 카드 뷰 */}
|
||||
<div className="xl:hidden space-y-4 md:grid md:grid-cols-2 md:gap-4 lg:grid-cols-3">
|
||||
{projects.length === 0 ? (
|
||||
<div className="text-center py-6 text-muted-foreground border rounded-lg">
|
||||
검색 결과가 없습니다.
|
||||
</div>
|
||||
) : (
|
||||
projects.map((project, index) => {
|
||||
const isSelected = selectedItems.has(project.id);
|
||||
return (
|
||||
<MobileCard
|
||||
key={project.id}
|
||||
title={project.siteName}
|
||||
subtitle={project.contractNumber}
|
||||
badge={project.hasUrgentIssue ? '긴급' : project.status === 'completed' ? '완료' : '진행중'}
|
||||
badgeVariant={project.hasUrgentIssue ? 'destructive' : project.status === 'completed' ? 'secondary' : 'default'}
|
||||
isSelected={isSelected}
|
||||
onToggle={() => handleToggleSelection(project.id)}
|
||||
onClick={() => handleRowClick(project)}
|
||||
details={[
|
||||
{ label: '거래처', value: project.partnerName },
|
||||
{ label: '공사PM', value: project.constructionPM },
|
||||
{ label: '진행률', value: `${project.progressRate}%` },
|
||||
{ label: '계약금액', value: formatAmount(project.contractAmount) },
|
||||
]}
|
||||
/>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 페이지네이션 */}
|
||||
{totalPages > 1 && (
|
||||
<div className="hidden xl:flex items-center justify-between">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
전체 {projects.length}개 중 {startIndex + 1}-{Math.min(startIndex + itemsPerPage, projects.length)}개 표시
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setCurrentPage(currentPage - 1)}
|
||||
disabled={currentPage === 1}
|
||||
>
|
||||
이전
|
||||
</Button>
|
||||
<div className="flex items-center gap-1">
|
||||
{Array.from({ length: totalPages }, (_, i) => i + 1).map((page) => {
|
||||
if (
|
||||
page === 1 ||
|
||||
page === totalPages ||
|
||||
(page >= currentPage - 2 && page <= currentPage + 2)
|
||||
) {
|
||||
return (
|
||||
<Button
|
||||
key={page}
|
||||
variant={page === currentPage ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setCurrentPage(page)}
|
||||
className="min-w-[36px]"
|
||||
>
|
||||
{page}
|
||||
</Button>
|
||||
);
|
||||
} else if (page === currentPage - 3 || page === currentPage + 3) {
|
||||
return <span key={page} className="px-2">...</span>;
|
||||
}
|
||||
return null;
|
||||
})}
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setCurrentPage(currentPage + 1)}
|
||||
disabled={currentPage === totalPages}
|
||||
>
|
||||
다음
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
'use client';
|
||||
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { Stage, StageCardStatus } from './types';
|
||||
import { STAGE_LABELS, STAGE_CARD_STATUS_LABELS } from './types';
|
||||
|
||||
interface StageCardProps {
|
||||
stage: Stage;
|
||||
isSelected?: boolean;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export default function StageCard({ stage, isSelected, onClick }: StageCardProps) {
|
||||
// 상태 뱃지 색상
|
||||
const getStatusBadge = (status: StageCardStatus) => {
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
return <Badge variant="secondary" className="text-xs">완료</Badge>;
|
||||
case 'in_progress':
|
||||
return <Badge className="text-xs bg-yellow-500">진행중</Badge>;
|
||||
case 'waiting':
|
||||
return <Badge variant="outline" className="text-xs">대기</Badge>;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// 금액 포맷
|
||||
const formatAmount = (amount: number) => {
|
||||
return amount.toLocaleString();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'p-3 bg-card rounded-lg border cursor-pointer transition-all hover:shadow-md',
|
||||
isSelected && 'ring-2 ring-primary border-primary'
|
||||
)}
|
||||
onClick={onClick}
|
||||
>
|
||||
{/* 헤더: 단계명 + 상태 뱃지 */}
|
||||
<div className="flex items-center justify-between gap-2 mb-2">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
{STAGE_LABELS[stage.type]}
|
||||
</span>
|
||||
{getStatusBadge(stage.status)}
|
||||
</div>
|
||||
|
||||
{/* 현장명 */}
|
||||
<h4 className="text-sm font-medium text-foreground mb-2 line-clamp-1">
|
||||
{stage.siteName}
|
||||
</h4>
|
||||
|
||||
{/* 세부 정보 */}
|
||||
<div className="space-y-1 text-xs text-muted-foreground">
|
||||
{stage.date && (
|
||||
<div className="flex justify-between">
|
||||
<span>날짜</span>
|
||||
<span>{stage.date.replace(/-/g, '.')}</span>
|
||||
</div>
|
||||
)}
|
||||
{stage.amount && (
|
||||
<div className="flex justify-between">
|
||||
<span>금액</span>
|
||||
<span className="font-medium text-foreground">{formatAmount(stage.amount)}원</span>
|
||||
</div>
|
||||
)}
|
||||
{stage.count && (
|
||||
<div className="flex justify-between">
|
||||
<span>개소</span>
|
||||
<span>{stage.count}개소</span>
|
||||
</div>
|
||||
)}
|
||||
{stage.pm && (
|
||||
<div className="flex justify-between">
|
||||
<span>담당PM</span>
|
||||
<span>{stage.pm}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
1223
src/components/business/construction/management/actions.ts
Normal file
1223
src/components/business/construction/management/actions.ts
Normal file
File diff suppressed because it is too large
Load Diff
4
src/components/business/construction/management/index.ts
Normal file
4
src/components/business/construction/management/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export { default as ProjectListClient } from './ProjectListClient';
|
||||
export { default as ProjectGanttChart } from './ProjectGanttChart';
|
||||
export * from './types';
|
||||
export * from './actions';
|
||||
460
src/components/business/construction/management/types.ts
Normal file
460
src/components/business/construction/management/types.ts
Normal file
@@ -0,0 +1,460 @@
|
||||
/**
|
||||
* 프로젝트 관리 타입 정의
|
||||
*/
|
||||
|
||||
// 프로젝트 상태
|
||||
export type ProjectStatus = 'in_progress' | 'completed' | 'urgent';
|
||||
|
||||
// 프로젝트 타입
|
||||
export interface Project {
|
||||
id: string;
|
||||
contractNumber: string; // 계약번호
|
||||
partnerName: string; // 거래처명
|
||||
siteName: string; // 현장명
|
||||
contractManager: string; // 계약담당자
|
||||
constructionPM: string; // 공사PM
|
||||
totalLocations: number; // 총 개소
|
||||
contractAmount: number; // 계약금액
|
||||
progressRate: number; // 진행률 (0-100)
|
||||
accumulatedPayment: number; // 누계 기성
|
||||
startDate: string; // 프로젝트 시작일
|
||||
endDate: string; // 프로젝트 종료일
|
||||
status: ProjectStatus; // 상태
|
||||
hasUrgentIssue: boolean; // 긴급 이슈 여부
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
// 프로젝트 통계
|
||||
export interface ProjectStats {
|
||||
total: number; // 전체 프로젝트
|
||||
inProgress: number; // 프로젝트 진행
|
||||
completed: number; // 프로젝트 완료
|
||||
}
|
||||
|
||||
// 프로젝트 필터
|
||||
export interface ProjectFilter {
|
||||
partners?: string[]; // 거래처 (다중선택)
|
||||
sites?: string[]; // 현장 (다중선택)
|
||||
contractManagers?: string[]; // 계약담당자 (다중선택)
|
||||
constructionPMs?: string[]; // 공사PM (다중선택)
|
||||
status?: string; // 상태 (단일선택)
|
||||
sortBy?: 'latest' | 'progress' | 'register' | 'completion'; // 정렬
|
||||
page?: number;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
// 기간 탭 타입
|
||||
export type PeriodTab = 'thisYear' | 'twoMonthsAgo' | 'lastMonth' | 'thisMonth' | 'yesterday' | 'today';
|
||||
|
||||
// 차트 뷰 모드
|
||||
export type ChartViewMode = 'day' | 'week' | 'month';
|
||||
|
||||
// API 응답 타입
|
||||
export interface ProjectListResponse {
|
||||
items: Project[];
|
||||
total: number;
|
||||
page: number;
|
||||
size: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
// 셀렉트 옵션
|
||||
export interface SelectOption {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
// 기간 탭 옵션
|
||||
export const PERIOD_TAB_OPTIONS: { value: PeriodTab; label: string }[] = [
|
||||
{ value: 'thisYear', label: '당해년도' },
|
||||
{ value: 'twoMonthsAgo', label: '전전월' },
|
||||
{ value: 'lastMonth', label: '전월' },
|
||||
{ value: 'thisMonth', label: '당월' },
|
||||
{ value: 'yesterday', label: '어제' },
|
||||
{ value: 'today', label: '오늘' },
|
||||
];
|
||||
|
||||
// 상태 옵션
|
||||
export const STATUS_OPTIONS: SelectOption[] = [
|
||||
{ value: 'all', label: '전체' },
|
||||
{ value: 'in_progress', label: '진행중' },
|
||||
{ value: 'completed', label: '완료' },
|
||||
];
|
||||
|
||||
// 정렬 옵션
|
||||
export const SORT_OPTIONS: SelectOption[] = [
|
||||
{ value: 'latest', label: '최신순' },
|
||||
{ value: 'progress', label: '진전순' },
|
||||
{ value: 'register', label: '등록순' },
|
||||
{ value: 'completion', label: '완성일순' },
|
||||
];
|
||||
|
||||
// 간트차트 막대 색상
|
||||
export const GANTT_BAR_COLORS = {
|
||||
completed: '#9CA3AF', // 회색 - 종료
|
||||
in_progress: '#3B82F6', // 파란색 - 진행중
|
||||
urgent: '#991B1B', // 버건디 - 긴급 이슈
|
||||
} as const;
|
||||
|
||||
// ============================================
|
||||
// 프로젝트 실행관리 상세 페이지 타입
|
||||
// ============================================
|
||||
|
||||
// 단계 타입
|
||||
export type StageType = 'bid' | 'contract' | 'order' | 'construction' | 'payment';
|
||||
|
||||
// 단계 라벨
|
||||
export const STAGE_LABELS: Record<StageType, string> = {
|
||||
bid: '입찰',
|
||||
contract: '계약',
|
||||
order: '발주',
|
||||
construction: '시공',
|
||||
payment: '기성청구',
|
||||
};
|
||||
|
||||
// 상세 항목 타입 (하위 목록 없는 경우)
|
||||
export type DetailType =
|
||||
| 'site_briefing' // 현장설명회
|
||||
| 'estimation' // 건적
|
||||
| 'bid_result' // 입찰
|
||||
| 'handover_report' // 인수인계보고서
|
||||
| 'structure_review' // 구조검토
|
||||
| 'completion'; // 종료
|
||||
|
||||
// 상세 항목 라벨 및 날짜 필드명
|
||||
export const DETAIL_CONFIG: Record<DetailType, { label: string; dateLabel: string }> = {
|
||||
site_briefing: { label: '현장설명회', dateLabel: '현장설명회일' },
|
||||
estimation: { label: '건적', dateLabel: '건적완료일' },
|
||||
bid_result: { label: '입찰', dateLabel: '확정일' },
|
||||
handover_report: { label: '인수인계보고서', dateLabel: '공사PM' },
|
||||
structure_review: { label: '구조검토', dateLabel: '구조검토완료일' },
|
||||
completion: { label: '종료', dateLabel: '결선완료일' },
|
||||
};
|
||||
|
||||
// 단계 카드 상태
|
||||
export type StageCardStatus = 'waiting' | 'in_progress' | 'completed';
|
||||
|
||||
// 단계 카드 상태 라벨
|
||||
export const STAGE_CARD_STATUS_LABELS: Record<StageCardStatus, string> = {
|
||||
waiting: '대기',
|
||||
in_progress: '진행중',
|
||||
completed: '완료',
|
||||
};
|
||||
|
||||
// 단계 데이터
|
||||
export interface Stage {
|
||||
id: string;
|
||||
type: StageType;
|
||||
siteName: string;
|
||||
status: StageCardStatus;
|
||||
date?: string; // 해당 단계 날짜
|
||||
amount?: number; // 금액 (계약금 등)
|
||||
count?: number; // 개소 수
|
||||
pm?: string; // 담당 PM
|
||||
}
|
||||
|
||||
// 상세 항목 데이터 (하위 목록 없는 경우)
|
||||
export interface StageDetail {
|
||||
id: string;
|
||||
type: DetailType;
|
||||
title: string; // 제목 (현장명 등)
|
||||
date?: string; // 날짜
|
||||
pm?: string; // 담당자 (인수인계보고서용)
|
||||
status?: StageCardStatus;
|
||||
}
|
||||
|
||||
// 프로젝트 상세 (칸반 보드용)
|
||||
export interface ProjectDetail extends Project {
|
||||
stages: Stage[]; // 단계 목록
|
||||
details: StageDetail[]; // 상세 목록
|
||||
detailCategories?: DetailCategory[]; // 상세 카테고리 (시공, 이슈 아코디언)
|
||||
}
|
||||
|
||||
// 프로젝트 종료 폼 데이터
|
||||
export interface ProjectEndFormData {
|
||||
projectId: string;
|
||||
projectName: string; // 현장명 (읽기전용)
|
||||
workDate: string; // 결선작업일 (읽기전용)
|
||||
completionDate: string; // 결선완료일
|
||||
status: 'in_progress' | 'completed'; // 상태
|
||||
memo: string; // 메모
|
||||
}
|
||||
|
||||
// 프로젝트 종료 상태 옵션
|
||||
export const PROJECT_END_STATUS_OPTIONS: SelectOption[] = [
|
||||
{ value: 'in_progress', label: '프로젝트 진행' },
|
||||
{ value: 'completed', label: '프로젝트 완료' },
|
||||
];
|
||||
|
||||
// ============================================
|
||||
// 상세 컬럼 아코디언 구조 타입
|
||||
// ============================================
|
||||
|
||||
// 상세 카테고리 타입 (시공, 이슈 등)
|
||||
export type DetailCategoryType = 'construction' | 'issue';
|
||||
|
||||
// 상세 카테고리 라벨
|
||||
export const DETAIL_CATEGORY_LABELS: Record<DetailCategoryType, string> = {
|
||||
construction: '시공',
|
||||
issue: '이슈',
|
||||
};
|
||||
|
||||
// 시공 상태
|
||||
export type ConstructionStatus = 'in_progress' | 'completed';
|
||||
|
||||
// 시공 상태 라벨
|
||||
export const CONSTRUCTION_STATUS_LABELS: Record<ConstructionStatus, string> = {
|
||||
in_progress: '시공진행',
|
||||
completed: '시공완료',
|
||||
};
|
||||
|
||||
// 이슈 상태
|
||||
export type IssueStatus = 'open' | 'resolved';
|
||||
|
||||
// 이슈 상태 라벨
|
||||
export const ISSUE_STATUS_LABELS: Record<IssueStatus, string> = {
|
||||
open: '미해결',
|
||||
resolved: '해결완료',
|
||||
};
|
||||
|
||||
// 시공 상세 항목
|
||||
export interface ConstructionItem {
|
||||
id: string;
|
||||
number: string; // 번호 (123123)
|
||||
inputDate: string; // 시공투입일
|
||||
status: ConstructionStatus;
|
||||
}
|
||||
|
||||
// 이슈 상세 항목
|
||||
export interface IssueItem {
|
||||
id: string;
|
||||
number: string; // 번호
|
||||
title: string; // 이슈 제목
|
||||
status: IssueStatus;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
// 상세 카테고리 데이터
|
||||
export interface DetailCategory {
|
||||
type: DetailCategoryType;
|
||||
count: number;
|
||||
constructionItems?: ConstructionItem[];
|
||||
issueItems?: IssueItem[];
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 시공관리 리스트 페이지 타입
|
||||
// ============================================
|
||||
|
||||
// 시공관리 상태
|
||||
export type ConstructionManagementStatus = 'in_progress' | 'completed';
|
||||
|
||||
// 시공관리 상태 라벨
|
||||
export const CONSTRUCTION_MANAGEMENT_STATUS_LABELS: Record<ConstructionManagementStatus, string> = {
|
||||
in_progress: '시공진행',
|
||||
completed: '시공완료',
|
||||
};
|
||||
|
||||
// 시공관리 상태 스타일
|
||||
export const CONSTRUCTION_MANAGEMENT_STATUS_STYLES: Record<ConstructionManagementStatus, string> = {
|
||||
in_progress: 'bg-yellow-100 text-yellow-800',
|
||||
completed: 'bg-green-100 text-green-800',
|
||||
};
|
||||
|
||||
// 시공관리 리스트 아이템
|
||||
export interface ConstructionManagement {
|
||||
id: string;
|
||||
constructionNumber: string; // 시공번호
|
||||
partnerName: string; // 거래처
|
||||
siteName: string; // 현장명
|
||||
constructionPM: string; // 공사PM
|
||||
workTeamLeader: string; // 작업반장
|
||||
worker: string; // 작업자
|
||||
workerCount: number; // 작업자 인원수
|
||||
constructionStartDate: string; // 시공투입일
|
||||
constructionEndDate: string | null; // 시공완료일
|
||||
status: ConstructionManagementStatus;
|
||||
periodStart: string; // 달력용 시작일
|
||||
periodEnd: string; // 달력용 종료일
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
// 시공관리 통계
|
||||
export interface ConstructionManagementStats {
|
||||
total: number;
|
||||
inProgress: number;
|
||||
completed: number;
|
||||
}
|
||||
|
||||
// 시공관리 필터
|
||||
export interface ConstructionManagementFilter {
|
||||
partners?: string[]; // 거래처 (다중선택)
|
||||
sites?: string[]; // 현장명 (다중선택)
|
||||
constructionPMs?: string[]; // 공사PM (다중선택)
|
||||
workTeamLeaders?: string[]; // 작업반장 (다중선택)
|
||||
status?: string; // 상태 (단일선택)
|
||||
sortBy?: string; // 정렬
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
page?: number;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
// 시공관리 리스트 응답
|
||||
export interface ConstructionManagementListResponse {
|
||||
items: ConstructionManagement[];
|
||||
total: number;
|
||||
page: number;
|
||||
size: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
// 시공관리 상태 옵션
|
||||
export const CONSTRUCTION_MANAGEMENT_STATUS_OPTIONS: SelectOption[] = [
|
||||
{ value: 'all', label: '전체' },
|
||||
{ value: 'in_progress', label: '시공중' },
|
||||
{ value: 'completed', label: '완료' },
|
||||
];
|
||||
|
||||
// 시공관리 정렬 옵션
|
||||
export const CONSTRUCTION_MANAGEMENT_SORT_OPTIONS: SelectOption[] = [
|
||||
{ value: 'latest', label: '최신순' },
|
||||
{ value: 'register', label: '등록순' },
|
||||
{ value: 'completionDateDesc', label: '시공완료일 최신순' },
|
||||
{ value: 'partnerNameAsc', label: '거래처명 오름차' },
|
||||
{ value: 'partnerNameDesc', label: '거래처명 내림차' },
|
||||
];
|
||||
|
||||
// 시공관리 목업 거래처 목록
|
||||
export const MOCK_CM_PARTNERS: SelectOption[] = [
|
||||
{ value: 'partner1', label: '대한건설' },
|
||||
{ value: 'partner2', label: '삼성시공' },
|
||||
{ value: 'partner3', label: 'LG건설' },
|
||||
{ value: 'partner4', label: '현대건설' },
|
||||
{ value: 'partner5', label: 'SK건설' },
|
||||
];
|
||||
|
||||
// 시공관리 목업 현장 목록
|
||||
export const MOCK_CM_SITES: SelectOption[] = [
|
||||
{ value: 'site1', label: '서울 강남 현장' },
|
||||
{ value: 'site2', label: '부산 해운대 현장' },
|
||||
{ value: 'site3', label: '대전 유성 현장' },
|
||||
{ value: 'site4', label: '인천 송도 현장' },
|
||||
{ value: 'site5', label: '광주 북구 현장' },
|
||||
];
|
||||
|
||||
// 시공관리 목업 공사PM 목록
|
||||
export const MOCK_CM_CONSTRUCTION_PM: SelectOption[] = [
|
||||
{ value: 'pm1', label: '김철수' },
|
||||
{ value: 'pm2', label: '박민수' },
|
||||
{ value: 'pm3', label: '정대리' },
|
||||
{ value: 'pm4', label: '윤대리' },
|
||||
{ value: 'pm5', label: '오차장' },
|
||||
];
|
||||
|
||||
// 시공관리 목업 작업반장 목록
|
||||
export const MOCK_CM_WORK_TEAM_LEADERS: SelectOption[] = [
|
||||
{ value: 'leader1', label: '이반장' },
|
||||
{ value: 'leader2', label: '김반장' },
|
||||
{ value: 'leader3', label: '박반장' },
|
||||
{ value: 'leader4', label: '최반장' },
|
||||
{ value: 'leader5', label: '정반장' },
|
||||
];
|
||||
|
||||
// 시공관리 달력 색상 (작업반장별)
|
||||
export const getConstructionScheduleColor = (workTeamLeader: string): string => {
|
||||
const colorMap: Record<string, string> = {
|
||||
'이반장': '#3B82F6', // blue
|
||||
'김반장': '#EF4444', // red
|
||||
'박반장': '#22C55E', // green
|
||||
'최반장': '#F59E0B', // amber
|
||||
'정반장': '#8B5CF6', // purple
|
||||
};
|
||||
return colorMap[workTeamLeader] || '#6B7280'; // 기본 gray
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// 시공 상세 페이지 타입
|
||||
// ============================================
|
||||
|
||||
// 작업자 정보
|
||||
export interface WorkerInfo {
|
||||
id: string;
|
||||
workDate: string; // 작업일
|
||||
workers: string[]; // 작업자 목록 (다중선택)
|
||||
}
|
||||
|
||||
// 공과 정보
|
||||
export interface WorkProgressInfo {
|
||||
id: string;
|
||||
scheduleDate: string; // 일정 (날짜+시간)
|
||||
workName: string; // 공과명
|
||||
}
|
||||
|
||||
// 사진 정보
|
||||
export interface PhotoInfo {
|
||||
id: string;
|
||||
url: string;
|
||||
name: string;
|
||||
uploadedAt: string;
|
||||
}
|
||||
|
||||
// 시공 상세 데이터
|
||||
export interface ConstructionManagementDetail {
|
||||
id: string;
|
||||
constructionNumber: string; // 시공번호
|
||||
siteName: string; // 현장
|
||||
constructionStartDate: string; // 시공투입일
|
||||
constructionEndDate: string | null; // 시공완료일
|
||||
workTeamLeader: string; // 작업반장
|
||||
status: ConstructionManagementStatus;
|
||||
|
||||
// 작업자 정보 (동적 테이블)
|
||||
workerInfoList: WorkerInfo[];
|
||||
|
||||
// 공과 정보 (동적 테이블)
|
||||
workProgressList: WorkProgressInfo[];
|
||||
|
||||
// 발주서 정보
|
||||
orderNumber: string; // 발주번호
|
||||
orderId: string; // 발주 ID (팝업용)
|
||||
|
||||
// 이슈 정보
|
||||
issueCount: number; // 이슈 건수
|
||||
|
||||
// 작업일지
|
||||
workLogContent: string; // 작업일지 내용
|
||||
|
||||
// 사진
|
||||
photos: PhotoInfo[];
|
||||
|
||||
// 이슈 보고 체크 여부
|
||||
isIssueReported: boolean;
|
||||
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
// 시공 상세 폼 데이터 (수정용)
|
||||
export interface ConstructionDetailFormData {
|
||||
workTeamLeader: string;
|
||||
workerInfoList: WorkerInfo[];
|
||||
workProgressList: WorkProgressInfo[];
|
||||
workLogContent: string;
|
||||
photos: PhotoInfo[];
|
||||
isIssueReported: boolean;
|
||||
}
|
||||
|
||||
// 목업 사원 목록 (작업자 선택용)
|
||||
export const MOCK_EMPLOYEES: SelectOption[] = [
|
||||
{ value: 'emp1', label: '홍길동' },
|
||||
{ value: 'emp2', label: '김영희' },
|
||||
{ value: 'emp3', label: '이철수' },
|
||||
{ value: 'emp4', label: '박민수' },
|
||||
{ value: 'emp5', label: '정대리' },
|
||||
{ value: 'emp6', label: '최과장' },
|
||||
{ value: 'emp7', label: '윤부장' },
|
||||
{ value: 'emp8', label: '오차장' },
|
||||
];
|
||||
@@ -8,6 +8,7 @@ import type { OrderDetail } from './types';
|
||||
import { useOrderDetailForm } from './hooks/useOrderDetailForm';
|
||||
import { OrderInfoCard } from './cards/OrderInfoCard';
|
||||
import { ContractInfoCard } from './cards/ContractInfoCard';
|
||||
import { ConstructionDetailCard } from './cards/ConstructionDetailCard';
|
||||
import { OrderScheduleCard } from './cards/OrderScheduleCard';
|
||||
import { OrderMemoCard } from './cards/OrderMemoCard';
|
||||
import { OrderDetailItemTable } from './tables/OrderDetailItemTable';
|
||||
@@ -159,6 +160,13 @@ export default function OrderDetailForm({
|
||||
onFieldChange={handleFieldChange}
|
||||
/>
|
||||
|
||||
{/* 시공 상세 */}
|
||||
<ConstructionDetailCard
|
||||
formData={formData}
|
||||
isViewMode={isViewMode}
|
||||
onFieldChange={handleFieldChange}
|
||||
/>
|
||||
|
||||
{/* 발주 스케줄 (달력) */}
|
||||
<OrderScheduleCard
|
||||
events={calendarEvents}
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { MultiSelectCombobox, MultiSelectOption } from '@/components/ui/multi-select-combobox';
|
||||
import { IntegratedListTemplateV2, TableColumn, StatCard } from '@/components/templates/IntegratedListTemplateV2';
|
||||
import { IntegratedListTemplateV2, TableColumn, StatCard, FilterFieldConfig, FilterValues } from '@/components/templates/IntegratedListTemplateV2';
|
||||
import { MobileCard } from '@/components/molecules/MobileCard';
|
||||
import { DateRangeSelector } from '@/components/molecules/DateRangeSelector';
|
||||
import { ScheduleCalendar, ScheduleEvent, DayBadge } from '@/components/common/ScheduleCalendar';
|
||||
@@ -70,9 +70,8 @@ const tableColumns: TableColumn[] = [
|
||||
{ key: 'orderType', label: '구분', className: 'w-[80px] text-center' },
|
||||
{ key: 'item', label: '품목', className: 'w-[80px]' },
|
||||
{ key: 'quantity', label: '수량', className: 'w-[60px] text-right' },
|
||||
{ key: 'orderDate', label: '발주일', className: 'w-[90px]' },
|
||||
{ key: 'plannedDeliveryDate', label: '계획납품일', className: 'w-[90px]' },
|
||||
{ key: 'actualDeliveryDate', label: '실제납품일', className: 'w-[90px]' },
|
||||
{ key: 'orderDate', label: '발주일', className: 'w-[90px]' }, { key: 'plannedDeliveryDate', label: '계획인수일', className: 'w-[90px]' },
|
||||
{ key: 'actualDeliveryDate', label: '실제인수일', className: 'w-[90px]' },
|
||||
{ key: 'status', label: '상태', className: 'w-[80px] text-center' },
|
||||
{ key: 'actions', label: '작업', className: 'w-[80px] text-center' },
|
||||
];
|
||||
@@ -568,41 +567,41 @@ export default function OrderManagementListClient({
|
||||
/>
|
||||
);
|
||||
|
||||
// Stats 카드 데이터
|
||||
const statsCardsData: StatCard[] = [
|
||||
{
|
||||
label: '전체 발주',
|
||||
value: stats?.total ?? 0,
|
||||
icon: Package,
|
||||
iconColor: 'text-blue-600',
|
||||
onClick: () => setActiveStatTab('all'),
|
||||
isActive: activeStatTab === 'all',
|
||||
},
|
||||
{
|
||||
label: '발주대기',
|
||||
value: stats?.waiting ?? 0,
|
||||
icon: Clock,
|
||||
iconColor: 'text-yellow-600',
|
||||
onClick: () => setActiveStatTab('waiting'),
|
||||
isActive: activeStatTab === 'waiting',
|
||||
},
|
||||
{
|
||||
label: '발주완료',
|
||||
value: stats?.orderComplete ?? 0,
|
||||
icon: AlertCircle,
|
||||
iconColor: 'text-blue-600',
|
||||
onClick: () => setActiveStatTab('order_complete'),
|
||||
isActive: activeStatTab === 'order_complete',
|
||||
},
|
||||
{
|
||||
label: '납품완료',
|
||||
value: stats?.deliveryComplete ?? 0,
|
||||
icon: CheckCircle,
|
||||
iconColor: 'text-green-600',
|
||||
onClick: () => setActiveStatTab('delivery_complete'),
|
||||
isActive: activeStatTab === 'delivery_complete',
|
||||
},
|
||||
];
|
||||
// Stats 카드 데이터 - 기획서에 없어서 주석 처리
|
||||
// const statsCardsData: StatCard[] = [
|
||||
// {
|
||||
// label: '전체 발주',
|
||||
// value: stats?.total ?? 0,
|
||||
// icon: Package,
|
||||
// iconColor: 'text-blue-600',
|
||||
// onClick: () => setActiveStatTab('all'),
|
||||
// isActive: activeStatTab === 'all',
|
||||
// },
|
||||
// {
|
||||
// label: '발주대기',
|
||||
// value: stats?.waiting ?? 0,
|
||||
// icon: Clock,
|
||||
// iconColor: 'text-yellow-600',
|
||||
// onClick: () => setActiveStatTab('waiting'),
|
||||
// isActive: activeStatTab === 'waiting',
|
||||
// },
|
||||
// {
|
||||
// label: '발주완료',
|
||||
// value: stats?.orderComplete ?? 0,
|
||||
// icon: AlertCircle,
|
||||
// iconColor: 'text-blue-600',
|
||||
// onClick: () => setActiveStatTab('order_complete'),
|
||||
// isActive: activeStatTab === 'order_complete',
|
||||
// },
|
||||
// {
|
||||
// label: '납품완료',
|
||||
// value: stats?.deliveryComplete ?? 0,
|
||||
// icon: CheckCircle,
|
||||
// iconColor: 'text-green-600',
|
||||
// onClick: () => setActiveStatTab('delivery_complete'),
|
||||
// isActive: activeStatTab === 'delivery_complete',
|
||||
// },
|
||||
// ];
|
||||
|
||||
// 필터 옵션들
|
||||
const partnerOptions: MultiSelectOption[] = useMemo(() => MOCK_PARTNERS, []);
|
||||
@@ -611,8 +610,82 @@ export default function OrderManagementListClient({
|
||||
const orderCompanyOptions: MultiSelectOption[] = useMemo(() => MOCK_ORDER_COMPANIES, []);
|
||||
const orderTypeOptions: MultiSelectOption[] = useMemo(() => ORDER_TYPE_OPTIONS, []);
|
||||
|
||||
// 테이블 헤더 액션 (기획서 요구사항)
|
||||
// 거래처, 현장명, 공사PM, 발주담당자, 발주처, 작업반장, 구분, 상태, 최신순
|
||||
// ===== filterConfig 기반 통합 필터 시스템 =====
|
||||
const filterConfig: FilterFieldConfig[] = useMemo(() => [
|
||||
{ key: 'partners', label: '거래처', type: 'multi', options: partnerOptions },
|
||||
{ key: 'sites', label: '현장명', type: 'multi', options: siteOptions },
|
||||
{ key: 'constructionPMs', label: '공사PM', type: 'multi', options: constructionPMOptions },
|
||||
{ key: 'orderManagers', label: '발주담당자', type: 'multi', options: orderManagerOptions },
|
||||
{ key: 'orderCompanies', label: '발주처', type: 'multi', options: orderCompanyOptions },
|
||||
{ key: 'workTeamLeaders', label: '작업반장', type: 'multi', options: workTeamOptions },
|
||||
{ key: 'orderTypes', label: '구분', type: 'multi', options: orderTypeOptions },
|
||||
{ key: 'status', label: '상태', type: 'single', options: ORDER_STATUS_OPTIONS.filter(opt => opt.value !== 'all') },
|
||||
{ key: 'sortBy', label: '정렬', type: 'single', options: ORDER_SORT_OPTIONS, allOptionLabel: '최신순' },
|
||||
], [partnerOptions, siteOptions, constructionPMOptions, orderManagerOptions, orderCompanyOptions, workTeamOptions, orderTypeOptions]);
|
||||
|
||||
// filterValues 객체
|
||||
const filterValues: FilterValues = useMemo(() => ({
|
||||
partners: partnerFilters,
|
||||
sites: siteNameFilters,
|
||||
constructionPMs: constructionPMFilters,
|
||||
orderManagers: orderManagerFilters,
|
||||
orderCompanies: orderCompanyFilters,
|
||||
workTeamLeaders: workTeamFilters,
|
||||
orderTypes: orderTypeFilters,
|
||||
status: statusFilter,
|
||||
sortBy: sortBy,
|
||||
}), [partnerFilters, siteNameFilters, constructionPMFilters, orderManagerFilters, orderCompanyFilters, workTeamFilters, orderTypeFilters, statusFilter, sortBy]);
|
||||
|
||||
// 필터 변경 핸들러
|
||||
const handleFilterChange = useCallback((key: string, value: string | string[]) => {
|
||||
switch (key) {
|
||||
case 'partners':
|
||||
setPartnerFilters(value as string[]);
|
||||
break;
|
||||
case 'sites':
|
||||
setSiteNameFilters(value as string[]);
|
||||
break;
|
||||
case 'constructionPMs':
|
||||
setConstructionPMFilters(value as string[]);
|
||||
break;
|
||||
case 'orderManagers':
|
||||
setOrderManagerFilters(value as string[]);
|
||||
break;
|
||||
case 'orderCompanies':
|
||||
setOrderCompanyFilters(value as string[]);
|
||||
break;
|
||||
case 'workTeamLeaders':
|
||||
setWorkTeamFilters(value as string[]);
|
||||
break;
|
||||
case 'orderTypes':
|
||||
setOrderTypeFilters(value as string[]);
|
||||
break;
|
||||
case 'status':
|
||||
setStatusFilter(value as string);
|
||||
break;
|
||||
case 'sortBy':
|
||||
setSortBy(value as string);
|
||||
break;
|
||||
}
|
||||
setCurrentPage(1);
|
||||
}, []);
|
||||
|
||||
// 필터 초기화 핸들러
|
||||
const handleFilterReset = useCallback(() => {
|
||||
setPartnerFilters([]);
|
||||
setSiteNameFilters([]);
|
||||
setConstructionPMFilters([]);
|
||||
setOrderManagerFilters([]);
|
||||
setOrderCompanyFilters([]);
|
||||
setWorkTeamFilters([]);
|
||||
setOrderTypeFilters([]);
|
||||
setStatusFilter('all');
|
||||
setSortBy('latest');
|
||||
setCurrentPage(1);
|
||||
}, []);
|
||||
|
||||
// 테이블 헤더 추가 액션 (총건 표시 + 달력 날짜 필터 해제)
|
||||
// 필터는 filterConfig로 자동 생성됨
|
||||
const tableHeaderActions = (
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{/* 총건 표시 */}
|
||||
@@ -625,104 +698,6 @@ export default function OrderManagementListClient({
|
||||
)}
|
||||
</span>
|
||||
|
||||
{/* 1. 거래처 필터 (다중선택) */}
|
||||
<MultiSelectCombobox
|
||||
options={partnerOptions}
|
||||
value={partnerFilters}
|
||||
onChange={setPartnerFilters}
|
||||
placeholder="거래처"
|
||||
searchPlaceholder="거래처 검색..."
|
||||
className="w-[120px]"
|
||||
/>
|
||||
|
||||
{/* 2. 현장명 필터 (다중선택) */}
|
||||
<MultiSelectCombobox
|
||||
options={siteOptions}
|
||||
value={siteNameFilters}
|
||||
onChange={setSiteNameFilters}
|
||||
placeholder="현장명"
|
||||
searchPlaceholder="현장명 검색..."
|
||||
className="w-[140px]"
|
||||
/>
|
||||
|
||||
{/* 3. 공사PM 필터 (다중선택) */}
|
||||
<MultiSelectCombobox
|
||||
options={constructionPMOptions}
|
||||
value={constructionPMFilters}
|
||||
onChange={setConstructionPMFilters}
|
||||
placeholder="공사PM"
|
||||
searchPlaceholder="공사PM 검색..."
|
||||
className="w-[120px]"
|
||||
/>
|
||||
|
||||
{/* 4. 발주담당자 필터 (다중선택) */}
|
||||
<MultiSelectCombobox
|
||||
options={orderManagerOptions}
|
||||
value={orderManagerFilters}
|
||||
onChange={setOrderManagerFilters}
|
||||
placeholder="발주담당자"
|
||||
searchPlaceholder="발주담당자 검색..."
|
||||
className="w-[120px]"
|
||||
/>
|
||||
|
||||
{/* 5. 발주처 필터 (다중선택) */}
|
||||
<MultiSelectCombobox
|
||||
options={orderCompanyOptions}
|
||||
value={orderCompanyFilters}
|
||||
onChange={setOrderCompanyFilters}
|
||||
placeholder="발주처"
|
||||
searchPlaceholder="발주처 검색..."
|
||||
className="w-[100px]"
|
||||
/>
|
||||
|
||||
{/* 6. 작업반장 필터 (다중선택) */}
|
||||
<MultiSelectCombobox
|
||||
options={workTeamOptions}
|
||||
value={workTeamFilters}
|
||||
onChange={setWorkTeamFilters}
|
||||
placeholder="작업반장"
|
||||
searchPlaceholder="작업반장 검색..."
|
||||
className="w-[110px]"
|
||||
/>
|
||||
|
||||
{/* 7. 구분 필터 (다중선택) */}
|
||||
<MultiSelectCombobox
|
||||
options={orderTypeOptions}
|
||||
value={orderTypeFilters}
|
||||
onChange={setOrderTypeFilters}
|
||||
placeholder="구분"
|
||||
searchPlaceholder="구분 검색..."
|
||||
className="w-[100px]"
|
||||
/>
|
||||
|
||||
{/* 8. 상태 필터 (단일선택) */}
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<SelectTrigger className="w-[100px]">
|
||||
<SelectValue placeholder="상태" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ORDER_STATUS_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* 9. 최신순 필터 (단일선택) */}
|
||||
<Select value={sortBy} onValueChange={setSortBy}>
|
||||
<SelectTrigger className="w-[100px]">
|
||||
<SelectValue placeholder="정렬" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ORDER_SORT_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* 달력 날짜 필터 초기화 */}
|
||||
{selectedCalendarDate && (
|
||||
<Button
|
||||
@@ -767,7 +742,13 @@ export default function OrderManagementListClient({
|
||||
description="발주 스케줄 및 목록을 관리합니다"
|
||||
icon={Package}
|
||||
headerActions={headerActions}
|
||||
stats={statsCardsData}
|
||||
// stats={statsCardsData} // 기획서에 없어서 주석 처리
|
||||
// 통합 필터 시스템 - PC는 인라인, 모바일은 바텀시트 자동 분기
|
||||
filterConfig={filterConfig}
|
||||
filterValues={filterValues}
|
||||
onFilterChange={handleFilterChange}
|
||||
onFilterReset={handleFilterReset}
|
||||
filterTitle="발주 필터"
|
||||
tableHeaderActions={tableHeaderActions}
|
||||
searchValue={searchValue}
|
||||
onSearchChange={handleSearchChange}
|
||||
@@ -802,7 +783,7 @@ export default function OrderManagementListClient({
|
||||
onMonthChange={handleCalendarMonthChange}
|
||||
titleSlot="발주 스케줄"
|
||||
filterSlot={calendarFilterSlot}
|
||||
maxEventsPerDay={3}
|
||||
maxEventsPerDay={5}
|
||||
weekStartsOn={0}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
'use client';
|
||||
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import type { OrderDetailFormData } from '../types';
|
||||
import { MOCK_WORK_TEAM_LEADERS } from '../types';
|
||||
|
||||
interface ConstructionDetailCardProps {
|
||||
formData: OrderDetailFormData;
|
||||
isViewMode: boolean;
|
||||
onFieldChange: (field: keyof OrderDetailFormData, value: string) => void;
|
||||
}
|
||||
|
||||
export function ConstructionDetailCard({
|
||||
formData,
|
||||
isViewMode,
|
||||
onFieldChange,
|
||||
}: ConstructionDetailCardProps) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">시공 상세</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* 작업반장 */}
|
||||
<div className="space-y-2">
|
||||
<Label>작업반장</Label>
|
||||
<Select
|
||||
value={formData.workTeamLeader}
|
||||
onValueChange={(value) => onFieldChange('workTeamLeader', value)}
|
||||
disabled={isViewMode}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{MOCK_WORK_TEAM_LEADERS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.label}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 시공투입일 ~ 시공완료일 */}
|
||||
<div className="space-y-2">
|
||||
<Label>
|
||||
시공투입일<span className="text-destructive ml-1">*</span>
|
||||
<span className="mx-2">~</span>
|
||||
시공완료일
|
||||
</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="date"
|
||||
value={formData.constructionStartDate}
|
||||
onChange={(e) => onFieldChange('constructionStartDate', e.target.value)}
|
||||
disabled={isViewMode}
|
||||
required
|
||||
className="flex-1"
|
||||
/>
|
||||
<span className="text-muted-foreground">~</span>
|
||||
<Input
|
||||
type="date"
|
||||
value={formData.constructionEndDate}
|
||||
onChange={(e) => onFieldChange('constructionEndDate', e.target.value)}
|
||||
disabled={isViewMode}
|
||||
className="flex-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -40,7 +40,7 @@ export function ContractInfoCard({
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{/* 거래처명 */}
|
||||
<div className="space-y-2">
|
||||
<Label>거래처명</Label>
|
||||
<Label>거래처명<span className="text-destructive ml-1">*</span></Label>
|
||||
<Select
|
||||
value={formData.partnerId}
|
||||
onValueChange={(value) => {
|
||||
@@ -67,7 +67,7 @@ export function ContractInfoCard({
|
||||
|
||||
{/* 현장명 */}
|
||||
<div className="space-y-2">
|
||||
<Label>현장명</Label>
|
||||
<Label>현장명<span className="text-destructive ml-1">*</span></Label>
|
||||
<Input
|
||||
value={formData.siteName}
|
||||
onChange={(e) => onFieldChange('siteName', e.target.value)}
|
||||
@@ -77,7 +77,7 @@ export function ContractInfoCard({
|
||||
|
||||
{/* 계약번호 */}
|
||||
<div className="space-y-2">
|
||||
<Label>계약번호</Label>
|
||||
<Label>계약번호<span className="text-destructive ml-1">*</span></Label>
|
||||
<Input
|
||||
value={formData.contractNumber}
|
||||
onChange={(e) => onFieldChange('contractNumber', e.target.value)}
|
||||
@@ -87,7 +87,7 @@ export function ContractInfoCard({
|
||||
|
||||
{/* 공사PM */}
|
||||
<div className="space-y-2">
|
||||
<Label>공사PM</Label>
|
||||
<Label>공사PM<span className="text-destructive ml-1">*</span></Label>
|
||||
<Select
|
||||
value={formData.constructionPMId}
|
||||
onValueChange={(value) => {
|
||||
@@ -114,7 +114,7 @@ export function ContractInfoCard({
|
||||
|
||||
{/* 공사담당자 */}
|
||||
<div className="space-y-2 md:col-span-2 lg:col-span-4">
|
||||
<Label>공사담당자</Label>
|
||||
<Label>공사담당자<span className="text-destructive ml-1">*</span></Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{formData.constructionManagers.map((manager, index) => (
|
||||
<div key={index} className="flex items-center gap-1">
|
||||
|
||||
@@ -34,7 +34,7 @@ export function OrderInfoCard({ formData, isViewMode, onFieldChange }: OrderInfo
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{/* 발주번호 */}
|
||||
<div className="space-y-2">
|
||||
<Label>발주번호</Label>
|
||||
<Label>발주번호<span className="text-destructive ml-1">*</span></Label>
|
||||
<Input
|
||||
value={formData.orderNumber}
|
||||
onChange={(e) => onFieldChange('orderNumber', e.target.value)}
|
||||
@@ -44,7 +44,7 @@ export function OrderInfoCard({ formData, isViewMode, onFieldChange }: OrderInfo
|
||||
|
||||
{/* 발주일 (발주처) */}
|
||||
<div className="space-y-2">
|
||||
<Label>발주일</Label>
|
||||
<Label>발주일<span className="text-destructive ml-1">*</span></Label>
|
||||
<Select
|
||||
value={formData.orderCompanyId}
|
||||
onValueChange={(value) => onFieldChange('orderCompanyId', value)}
|
||||
@@ -65,7 +65,7 @@ export function OrderInfoCard({ formData, isViewMode, onFieldChange }: OrderInfo
|
||||
|
||||
{/* 구분 */}
|
||||
<div className="space-y-2">
|
||||
<Label>구분</Label>
|
||||
<Label>구분<span className="text-destructive ml-1">*</span></Label>
|
||||
<Select
|
||||
value={formData.orderType}
|
||||
onValueChange={(value) => onFieldChange('orderType', value as OrderType)}
|
||||
@@ -86,7 +86,7 @@ export function OrderInfoCard({ formData, isViewMode, onFieldChange }: OrderInfo
|
||||
|
||||
{/* 상태 */}
|
||||
<div className="space-y-2">
|
||||
<Label>상태</Label>
|
||||
<Label>상태<span className="text-destructive ml-1">*</span></Label>
|
||||
<Select
|
||||
value={formData.status}
|
||||
onValueChange={(value) => onFieldChange('status', value as OrderStatus)}
|
||||
@@ -107,7 +107,7 @@ export function OrderInfoCard({ formData, isViewMode, onFieldChange }: OrderInfo
|
||||
|
||||
{/* 발주담당자 */}
|
||||
<div className="space-y-2">
|
||||
<Label>발주담당자</Label>
|
||||
<Label>발주담당자<span className="text-destructive ml-1">*</span></Label>
|
||||
<Select
|
||||
value={formData.orderManager}
|
||||
onValueChange={(value) => onFieldChange('orderManager', value)}
|
||||
@@ -128,7 +128,7 @@ export function OrderInfoCard({ formData, isViewMode, onFieldChange }: OrderInfo
|
||||
|
||||
{/* 화물도착지 */}
|
||||
<div className="space-y-2">
|
||||
<Label>화물도착지</Label>
|
||||
<Label>화물도착지<span className="text-destructive ml-1">*</span></Label>
|
||||
<Input
|
||||
value={formData.deliveryAddress}
|
||||
onChange={(e) => onFieldChange('deliveryAddress', e.target.value)}
|
||||
|
||||
@@ -32,7 +32,7 @@ export function OrderScheduleCard({
|
||||
onDateClick={onDateClick}
|
||||
onEventClick={() => {}}
|
||||
onMonthChange={onMonthChange}
|
||||
maxEventsPerDay={3}
|
||||
maxEventsPerDay={5}
|
||||
weekStartsOn={0}
|
||||
isLoading={false}
|
||||
/>
|
||||
|
||||
@@ -125,7 +125,7 @@ export function OrderDocumentModal({
|
||||
{/* 기본 정보 테이블 */}
|
||||
<table className="w-full border-collapse border border-gray-300 text-sm mb-8">
|
||||
<tbody>
|
||||
{/* 출고일 / 작업팀 */}
|
||||
{/* 출고일 / 작업반장 */}
|
||||
<tr>
|
||||
<th className="border border-gray-300 px-4 py-3 bg-gray-50 text-center w-28 font-medium">
|
||||
출고일
|
||||
@@ -133,32 +133,32 @@ export function OrderDocumentModal({
|
||||
<td className="border border-gray-300 px-4 py-3">
|
||||
{formatDate(order.plannedDeliveryDate)}
|
||||
</td>
|
||||
<th className="border border-gray-300 px-4 py-3 bg-gray-50 text-center w-28 font-medium">
|
||||
작업팀
|
||||
<th className="border border-gray-300 px-4 py-3 bg-gray-50 text-center w-32 font-medium">
|
||||
작업반장
|
||||
</th>
|
||||
<td className="border border-gray-300 px-4 py-3">
|
||||
{order.workTeamLeader || '-'}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
{/* 현장명 / 연락처 */}
|
||||
{/* 현장명 / 작업반장 연락처 */}
|
||||
<tr>
|
||||
<th className="border border-gray-300 px-4 py-3 bg-gray-50 text-center font-medium">
|
||||
현장명
|
||||
</th>
|
||||
<td className="border border-gray-300 px-4 py-3">{order.siteName || '-'}</td>
|
||||
<th className="border border-gray-300 px-4 py-3 bg-gray-50 text-center font-medium">
|
||||
연락처
|
||||
작업반장 연락처
|
||||
</th>
|
||||
<td className="border border-gray-300 px-4 py-3">-</td>
|
||||
</tr>
|
||||
|
||||
{/* 화물 도착지 / 발주담당자 */}
|
||||
<tr>
|
||||
<th className="border border-gray-300 px-4 py-3 bg-gray-50 text-center font-medium">
|
||||
<th rowSpan={2} className="border border-gray-300 px-4 py-3 bg-gray-50 text-center font-medium align-middle">
|
||||
화물 도착지
|
||||
</th>
|
||||
<td className="border border-gray-300 px-4 py-3">{order.deliveryAddress || '-'}</td>
|
||||
<td rowSpan={2} className="border border-gray-300 px-4 py-3 align-middle">{order.deliveryAddress || '-'}</td>
|
||||
<th className="border border-gray-300 px-4 py-3 bg-gray-50 text-center font-medium">
|
||||
발주담당자
|
||||
</th>
|
||||
@@ -166,6 +166,14 @@ export function OrderDocumentModal({
|
||||
{order.orderManager || '-'}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
{/* 발주담당자 연락처 */}
|
||||
<tr>
|
||||
<th className="border border-gray-300 px-4 py-3 bg-gray-50 text-center font-medium">
|
||||
발주담당자 연락처
|
||||
</th>
|
||||
<td className="border border-gray-300 px-4 py-3">-</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
|
||||
@@ -166,8 +166,8 @@ export function OrderDetailItemTable({
|
||||
<TableHead className="w-[100px]">비고</TableHead>
|
||||
<TableHead className="w-[60px] text-center">이미지</TableHead>
|
||||
<TableHead className="w-[110px]">발주일</TableHead>
|
||||
<TableHead className="w-[110px]">계획납품일</TableHead>
|
||||
<TableHead className="w-[110px]">실제납품일</TableHead>
|
||||
<TableHead className="w-[110px]">계획인수일</TableHead>
|
||||
<TableHead className="w-[110px]">실제인수일</TableHead>
|
||||
<TableHead className="w-[100px]">상태</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
|
||||
@@ -48,9 +48,9 @@ export interface Order {
|
||||
quantity: number;
|
||||
/** 발주일 */
|
||||
orderDate: string;
|
||||
/** 계획납품일 */
|
||||
/** 계획인수일 */
|
||||
plannedDeliveryDate: string;
|
||||
/** 실제 납품일 */
|
||||
/** 실제 인수일 */
|
||||
actualDeliveryDate: string | null;
|
||||
/** 상태 */
|
||||
status: OrderStatus;
|
||||
@@ -74,9 +74,9 @@ export interface OrderStats {
|
||||
waiting: number;
|
||||
/** 발주완료 */
|
||||
orderComplete: number;
|
||||
/** 납품예정 */
|
||||
/** 인수예정 */
|
||||
deliveryScheduled: number;
|
||||
/** 납품완료 */
|
||||
/** 인수완료 */
|
||||
deliveryComplete: number;
|
||||
}
|
||||
|
||||
@@ -87,8 +87,8 @@ export const ORDER_STATUS_OPTIONS = [
|
||||
{ value: 'all', label: '전체' },
|
||||
{ value: 'waiting', label: '발주대기' },
|
||||
{ value: 'order_complete', label: '발주완료' },
|
||||
{ value: 'delivery_scheduled', label: '납품예정' },
|
||||
{ value: 'delivery_complete', label: '납품완료' },
|
||||
{ value: 'delivery_scheduled', label: '인수예정' },
|
||||
{ value: 'delivery_complete', label: '인수완료' },
|
||||
] as const;
|
||||
|
||||
/**
|
||||
@@ -97,8 +97,8 @@ export const ORDER_STATUS_OPTIONS = [
|
||||
export const ORDER_STATUS_LABELS: Record<OrderStatus, string> = {
|
||||
waiting: '발주대기',
|
||||
order_complete: '발주완료',
|
||||
delivery_scheduled: '납품예정',
|
||||
delivery_complete: '납품완료',
|
||||
delivery_scheduled: '인수예정',
|
||||
delivery_complete: '인수완료',
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -210,8 +210,8 @@ export const ORDER_SORT_OPTIONS = [
|
||||
{ value: 'partnerNameDesc', label: '거래처명 ↓' },
|
||||
{ value: 'siteNameAsc', label: '현장명 ↑' },
|
||||
{ value: 'siteNameDesc', label: '현장명 ↓' },
|
||||
{ value: 'deliveryDateAsc', label: '납품일 ↑' },
|
||||
{ value: 'deliveryDateDesc', label: '납품일 ↓' },
|
||||
{ value: 'deliveryDateAsc', label: '인수일 ↑' },
|
||||
{ value: 'deliveryDateDesc', label: '인수일 ↓' },
|
||||
] as const;
|
||||
|
||||
/**
|
||||
@@ -322,9 +322,9 @@ export interface OrderDetailItem {
|
||||
imageUrl: string;
|
||||
/** 발주일 */
|
||||
orderDate: string;
|
||||
/** 계획 납품일 */
|
||||
/** 계획 인수일 */
|
||||
plannedDeliveryDate: string;
|
||||
/** 실제 납품일 */
|
||||
/** 실제 인수일 */
|
||||
actualDeliveryDate: string;
|
||||
/** 상태 */
|
||||
status: OrderStatus;
|
||||
@@ -412,6 +412,12 @@ export interface OrderDetailFormData {
|
||||
constructionPM: string;
|
||||
/** 공사담당자 목록 */
|
||||
constructionManagers: string[];
|
||||
/** 작업반장 */
|
||||
workTeamLeader: string;
|
||||
/** 시공투입일 (필수) */
|
||||
constructionStartDate: string;
|
||||
/** 시공완료일 */
|
||||
constructionEndDate: string;
|
||||
/** 발주 상세 카테고리 목록 */
|
||||
orderCategories: OrderDetailCategory[];
|
||||
/** 비고 */
|
||||
@@ -518,6 +524,9 @@ export function getEmptyOrderDetailFormData(): OrderDetailFormData {
|
||||
constructionPMId: '',
|
||||
constructionPM: '',
|
||||
constructionManagers: [],
|
||||
workTeamLeader: '',
|
||||
constructionStartDate: '',
|
||||
constructionEndDate: '',
|
||||
orderCategories: [],
|
||||
memo: '',
|
||||
periodStart: '',
|
||||
@@ -563,6 +572,9 @@ export function orderDetailToFormData(detail: OrderDetail): OrderDetailFormData
|
||||
constructionPMId: detail.constructionPMId,
|
||||
constructionPM: detail.constructionPM,
|
||||
constructionManagers: detail.constructionManagers,
|
||||
workTeamLeader: detail.workTeamLeader,
|
||||
constructionStartDate: detail.constructionStartDate,
|
||||
constructionEndDate: '', // Order 인터페이스에는 없으므로 빈 값
|
||||
orderCategories: Array.from(categoryMap.values()),
|
||||
memo: detail.memo,
|
||||
periodStart: detail.periodStart,
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { IntegratedListTemplateV2, TabOption, TableColumn } from '@/components/templates/IntegratedListTemplateV2';
|
||||
import { IntegratedListTemplateV2, TabOption, TableColumn, FilterFieldConfig, FilterValues } from '@/components/templates/IntegratedListTemplateV2';
|
||||
import { MobileCard } from '@/components/molecules/MobileCard';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
@@ -375,6 +375,54 @@ export default function PartnerListClient({ initialData = [], initialStats }: Pa
|
||||
[handleRowClick]
|
||||
);
|
||||
|
||||
// ===== filterConfig 기반 통합 필터 시스템 =====
|
||||
const filterConfig: FilterFieldConfig[] = useMemo(() => [
|
||||
{
|
||||
key: 'badDebt',
|
||||
label: '악성채권',
|
||||
type: 'single',
|
||||
options: [
|
||||
{ value: 'badDebt', label: '악성채권' },
|
||||
{ value: 'normal', label: '정상' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'sortBy',
|
||||
label: '정렬',
|
||||
type: 'single',
|
||||
options: [
|
||||
{ value: 'latest', label: '최신순' },
|
||||
{ value: 'oldest', label: '등록순' },
|
||||
{ value: 'nameAsc', label: '이름 오름차순' },
|
||||
{ value: 'nameDesc', label: '이름 내림차순' },
|
||||
],
|
||||
allOptionLabel: '최신순',
|
||||
},
|
||||
], []);
|
||||
|
||||
const filterValues: FilterValues = useMemo(() => ({
|
||||
badDebt: badDebtFilter,
|
||||
sortBy: sortBy,
|
||||
}), [badDebtFilter, sortBy]);
|
||||
|
||||
const handleFilterChange = useCallback((key: string, value: string | string[]) => {
|
||||
switch (key) {
|
||||
case 'badDebt':
|
||||
setBadDebtFilter(value as 'all' | 'badDebt' | 'normal');
|
||||
break;
|
||||
case 'sortBy':
|
||||
setSortBy(value as 'latest' | 'oldest' | 'nameAsc' | 'nameDesc');
|
||||
break;
|
||||
}
|
||||
setCurrentPage(1);
|
||||
}, []);
|
||||
|
||||
const handleFilterReset = useCallback(() => {
|
||||
setBadDebtFilter('all');
|
||||
setSortBy('latest');
|
||||
setCurrentPage(1);
|
||||
}, []);
|
||||
|
||||
// 헤더 액션 (등록 버튼만)
|
||||
const headerActions = (
|
||||
<div className="flex items-center justify-end w-full">
|
||||
@@ -445,6 +493,11 @@ export default function PartnerListClient({ initialData = [], initialStats }: Pa
|
||||
activeTab={activeTab}
|
||||
onTabChange={handleTabChange}
|
||||
tableHeaderActions={tableHeaderActions}
|
||||
filterConfig={filterConfig}
|
||||
filterValues={filterValues}
|
||||
onFilterChange={handleFilterChange}
|
||||
onFilterReset={handleFilterReset}
|
||||
filterTitle="거래처 필터"
|
||||
searchValue={searchValue}
|
||||
onSearchChange={handleSearchChange}
|
||||
searchPlaceholder="거래처명, 거래처번호, 대표자 검색"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { DollarSign, List } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -51,8 +51,6 @@ interface FormData {
|
||||
unit: string;
|
||||
division: string;
|
||||
vendor: string;
|
||||
purchasePrice: number;
|
||||
marginRate: number;
|
||||
sellingPrice: number;
|
||||
status: PricingStatus;
|
||||
note: string;
|
||||
@@ -66,8 +64,6 @@ const initialFormData: FormData = {
|
||||
unit: '',
|
||||
division: '',
|
||||
vendor: '',
|
||||
purchasePrice: 0,
|
||||
marginRate: 0,
|
||||
sellingPrice: 0,
|
||||
status: 'in_use',
|
||||
note: '',
|
||||
@@ -109,8 +105,6 @@ export default function PricingDetailClient({ id, mode }: PricingDetailClientPro
|
||||
unit: result.data.unit,
|
||||
division: result.data.division,
|
||||
vendor: result.data.vendor,
|
||||
purchasePrice: result.data.purchasePrice,
|
||||
marginRate: result.data.marginRate,
|
||||
sellingPrice: result.data.sellingPrice,
|
||||
status: result.data.status,
|
||||
note: '',
|
||||
@@ -130,29 +124,12 @@ export default function PricingDetailClient({ id, mode }: PricingDetailClientPro
|
||||
loadData();
|
||||
}, [id, mode, isViewMode, isEditMode, router]);
|
||||
|
||||
// 판매단가 자동 계산: 매입단가 * (1 + 마진율/100)
|
||||
const calculatedSellingPrice = useMemo(() => {
|
||||
const price = formData.purchasePrice * (1 + formData.marginRate / 100);
|
||||
return Math.round(price);
|
||||
}, [formData.purchasePrice, formData.marginRate]);
|
||||
|
||||
// 매입단가 변경
|
||||
const handlePurchasePriceChange = useCallback((value: string) => {
|
||||
// 판매단가 변경
|
||||
const handleSellingPriceChange = useCallback((value: string) => {
|
||||
const numValue = parseFloat(value) || 0;
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
purchasePrice: numValue,
|
||||
sellingPrice: Math.round(numValue * (1 + prev.marginRate / 100)),
|
||||
}));
|
||||
}, []);
|
||||
|
||||
// 마진율 변경
|
||||
const handleMarginRateChange = useCallback((value: string) => {
|
||||
const numValue = parseFloat(value) || 0;
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
marginRate: numValue,
|
||||
sellingPrice: Math.round(prev.purchasePrice * (1 + numValue / 100)),
|
||||
sellingPrice: numValue,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
@@ -185,9 +162,7 @@ export default function PricingDetailClient({ id, mode }: PricingDetailClientPro
|
||||
unit: formData.unit,
|
||||
division: formData.division,
|
||||
vendor: formData.vendor,
|
||||
purchasePrice: formData.purchasePrice,
|
||||
marginRate: formData.marginRate,
|
||||
sellingPrice: calculatedSellingPrice,
|
||||
sellingPrice: formData.sellingPrice,
|
||||
status: formData.status,
|
||||
});
|
||||
|
||||
@@ -200,9 +175,7 @@ export default function PricingDetailClient({ id, mode }: PricingDetailClientPro
|
||||
} else if (isEditMode && id) {
|
||||
const result = await updatePricing(id, {
|
||||
vendor: formData.vendor,
|
||||
purchasePrice: formData.purchasePrice,
|
||||
marginRate: formData.marginRate,
|
||||
sellingPrice: calculatedSellingPrice,
|
||||
sellingPrice: formData.sellingPrice,
|
||||
status: formData.status,
|
||||
});
|
||||
|
||||
@@ -218,7 +191,7 @@ export default function PricingDetailClient({ id, mode }: PricingDetailClientPro
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [isCreateMode, isEditMode, id, formData, calculatedSellingPrice, router]);
|
||||
}, [isCreateMode, isEditMode, id, formData, router]);
|
||||
|
||||
// 삭제
|
||||
const handleDelete = useCallback(async () => {
|
||||
@@ -379,7 +352,7 @@ export default function PricingDetailClient({ id, mode }: PricingDetailClientPro
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 거래처 / 매입단가 */}
|
||||
{/* 거래처 / 판매단가 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>거래처</Label>
|
||||
@@ -400,48 +373,17 @@ export default function PricingDetailClient({ id, mode }: PricingDetailClientPro
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>매입단가</Label>
|
||||
{isViewMode ? (
|
||||
<Input value={formatNumber(formData.purchasePrice)} disabled />
|
||||
) : (
|
||||
<Input
|
||||
type="number"
|
||||
value={formData.purchasePrice}
|
||||
onChange={(e) => handlePurchasePriceChange(e.target.value)}
|
||||
placeholder="매입단가 입력"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 마진율 / 판매단가 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>마진율 (%)</Label>
|
||||
{isViewMode ? (
|
||||
<Input value={`${formData.marginRate}%`} disabled />
|
||||
) : (
|
||||
<Input
|
||||
type="number"
|
||||
step="0.1"
|
||||
value={formData.marginRate}
|
||||
onChange={(e) => handleMarginRateChange(e.target.value)}
|
||||
placeholder="마진율 입력"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>판매단가</Label>
|
||||
<Input
|
||||
value={formatNumber(isViewMode ? formData.sellingPrice : calculatedSellingPrice)}
|
||||
disabled
|
||||
className="bg-muted"
|
||||
/>
|
||||
{!isViewMode && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
매입단가 × (1 + 마진율) = 자동 계산
|
||||
</p>
|
||||
{isViewMode ? (
|
||||
<Input value={formatNumber(formData.sellingPrice)} disabled />
|
||||
) : (
|
||||
<Input
|
||||
type="number"
|
||||
value={formData.sellingPrice}
|
||||
onChange={(e) => handleSellingPriceChange(e.target.value)}
|
||||
placeholder="판매단가 입력"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -7,14 +7,7 @@ import { Button } from '@/components/ui/button';
|
||||
import { TableCell, TableRow, TableHead } from '@/components/ui/table';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { IntegratedListTemplateV2, StatCard } from '@/components/templates/IntegratedListTemplateV2';
|
||||
import { IntegratedListTemplateV2, StatCard, FilterFieldConfig, FilterValues } from '@/components/templates/IntegratedListTemplateV2';
|
||||
import { MobileCard } from '@/components/molecules/MobileCard';
|
||||
import { DateRangeSelector } from '@/components/molecules/DateRangeSelector';
|
||||
import { toast } from 'sonner';
|
||||
@@ -325,8 +318,6 @@ export default function PricingListClient({
|
||||
<TableHead className="w-[60px]">단위</TableHead>
|
||||
<TableHead className="w-[60px]">구분</TableHead>
|
||||
<TableHead className="w-[120px]">거래처</TableHead>
|
||||
<TableHead className="w-[100px] text-right">매입단가</TableHead>
|
||||
<TableHead className="w-[70px] text-right">마진율</TableHead>
|
||||
<TableHead className="w-[100px] text-right">판매단가</TableHead>
|
||||
<TableHead className="w-[70px] text-center">상태</TableHead>
|
||||
{selectedItems.size > 0 && (
|
||||
@@ -368,8 +359,6 @@ export default function PricingListClient({
|
||||
<TableCell>{pricing.unit}</TableCell>
|
||||
<TableCell>{pricing.division}</TableCell>
|
||||
<TableCell>{pricing.vendor}</TableCell>
|
||||
<TableCell className="text-right">{formatNumber(pricing.purchasePrice)}</TableCell>
|
||||
<TableCell className="text-right">{pricing.marginRate}%</TableCell>
|
||||
<TableCell className="text-right">{formatNumber(pricing.sellingPrice)}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Badge className={PRICING_STATUS_STYLES[pricing.status]}>
|
||||
@@ -473,98 +462,112 @@ export default function PricingListClient({
|
||||
},
|
||||
];
|
||||
|
||||
// 테이블 헤더 액션 (필터 6개)
|
||||
const tableHeaderActions = (
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-sm text-muted-foreground whitespace-nowrap">
|
||||
총 {sortedPricing.length}건
|
||||
</span>
|
||||
// ===== filterConfig 기반 통합 필터 시스템 =====
|
||||
const filterConfig: FilterFieldConfig[] = useMemo(() => [
|
||||
{
|
||||
key: 'itemType',
|
||||
label: '품목유형',
|
||||
type: 'single',
|
||||
options: ITEM_TYPE_OPTIONS.filter(o => o.value !== 'all').map(o => ({
|
||||
value: o.value,
|
||||
label: o.label,
|
||||
})),
|
||||
allOptionLabel: '전체',
|
||||
},
|
||||
{
|
||||
key: 'category',
|
||||
label: '카테고리',
|
||||
type: 'single',
|
||||
options: CATEGORY_OPTIONS.filter(o => o.value !== 'all').map(o => ({
|
||||
value: o.value,
|
||||
label: o.label,
|
||||
})),
|
||||
allOptionLabel: '전체',
|
||||
},
|
||||
{
|
||||
key: 'spec',
|
||||
label: '규격',
|
||||
type: 'single',
|
||||
options: SPEC_OPTIONS.filter(o => o.value !== 'all').map(o => ({
|
||||
value: o.value,
|
||||
label: o.label,
|
||||
})),
|
||||
allOptionLabel: '전체',
|
||||
},
|
||||
{
|
||||
key: 'division',
|
||||
label: '구분',
|
||||
type: 'single',
|
||||
options: DIVISION_OPTIONS.filter(o => o.value !== 'all').map(o => ({
|
||||
value: o.value,
|
||||
label: o.label,
|
||||
})),
|
||||
allOptionLabel: '전체',
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
label: '상태',
|
||||
type: 'single',
|
||||
options: STATUS_OPTIONS.filter(o => o.value !== 'all').map(o => ({
|
||||
value: o.value,
|
||||
label: o.label,
|
||||
})),
|
||||
allOptionLabel: '전체',
|
||||
},
|
||||
{
|
||||
key: 'sortBy',
|
||||
label: '정렬',
|
||||
type: 'single',
|
||||
options: SORT_OPTIONS.map(o => ({
|
||||
value: o.value,
|
||||
label: o.label,
|
||||
})),
|
||||
allOptionLabel: '최신순',
|
||||
},
|
||||
], []);
|
||||
|
||||
{/* 품목유형 필터 */}
|
||||
<Select value={itemTypeFilter} onValueChange={setItemTypeFilter}>
|
||||
<SelectTrigger className="w-[100px]">
|
||||
<SelectValue placeholder="품목유형" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ITEM_TYPE_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
const filterValues: FilterValues = useMemo(() => ({
|
||||
itemType: itemTypeFilter,
|
||||
category: categoryFilter,
|
||||
spec: specFilter,
|
||||
division: divisionFilter,
|
||||
status: statusFilter,
|
||||
sortBy: sortBy,
|
||||
}), [itemTypeFilter, categoryFilter, specFilter, divisionFilter, statusFilter, sortBy]);
|
||||
|
||||
{/* 카테고리 필터 */}
|
||||
<Select value={categoryFilter} onValueChange={setCategoryFilter}>
|
||||
<SelectTrigger className="w-[140px]">
|
||||
<SelectValue placeholder="카테고리" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{CATEGORY_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
const handleFilterChange = useCallback((key: string, value: string | string[]) => {
|
||||
switch (key) {
|
||||
case 'itemType':
|
||||
setItemTypeFilter(value as string);
|
||||
break;
|
||||
case 'category':
|
||||
setCategoryFilter(value as string);
|
||||
break;
|
||||
case 'spec':
|
||||
setSpecFilter(value as string);
|
||||
break;
|
||||
case 'division':
|
||||
setDivisionFilter(value as string);
|
||||
break;
|
||||
case 'status':
|
||||
setStatusFilter(value as string);
|
||||
break;
|
||||
case 'sortBy':
|
||||
setSortBy(value as string);
|
||||
break;
|
||||
}
|
||||
setCurrentPage(1);
|
||||
}, []);
|
||||
|
||||
{/* 규격 필터 */}
|
||||
<Select value={specFilter} onValueChange={setSpecFilter}>
|
||||
<SelectTrigger className="w-[90px]">
|
||||
<SelectValue placeholder="규격" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SPEC_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* 구분 필터 */}
|
||||
<Select value={divisionFilter} onValueChange={setDivisionFilter}>
|
||||
<SelectTrigger className="w-[110px]">
|
||||
<SelectValue placeholder="구분" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{DIVISION_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* 상태 필터 */}
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<SelectTrigger className="w-[90px]">
|
||||
<SelectValue placeholder="상태" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{STATUS_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* 정렬 */}
|
||||
<Select value={sortBy} onValueChange={setSortBy}>
|
||||
<SelectTrigger className="w-[110px]">
|
||||
<SelectValue placeholder="정렬" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SORT_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
const handleFilterReset = useCallback(() => {
|
||||
setItemTypeFilter('all');
|
||||
setCategoryFilter('all');
|
||||
setSpecFilter('all');
|
||||
setDivisionFilter('all');
|
||||
setStatusFilter('all');
|
||||
setSortBy('latest');
|
||||
setCurrentPage(1);
|
||||
}, []);
|
||||
|
||||
// 동적 컬럼으로 인해 tableColumns를 사용하지 않고 커스텀 헤더 사용
|
||||
const emptyTableColumns: { key: string; label: string; className?: string }[] = [];
|
||||
@@ -577,7 +580,11 @@ export default function PricingListClient({
|
||||
icon={DollarSign}
|
||||
headerActions={headerActions}
|
||||
stats={statsCardsData}
|
||||
tableHeaderActions={tableHeaderActions}
|
||||
filterConfig={filterConfig}
|
||||
filterValues={filterValues}
|
||||
onFilterChange={handleFilterChange}
|
||||
onFilterReset={handleFilterReset}
|
||||
filterTitle="단가 필터"
|
||||
searchValue={searchValue}
|
||||
onSearchChange={handleSearchChange}
|
||||
searchPlaceholder="단가번호, 품목명, 카테고리, 거래처 검색"
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
'use client';
|
||||
|
||||
import { FileText, List, Eye, Edit } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { PageLayout } from '@/components/organisms/PageLayout';
|
||||
import { PageHeader } from '@/components/organisms/PageHeader';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import type { ProgressBillingDetail } from './types';
|
||||
import { useProgressBillingDetailForm } from './hooks/useProgressBillingDetailForm';
|
||||
import { ProgressBillingInfoCard } from './cards/ProgressBillingInfoCard';
|
||||
import { ContractInfoCard } from './cards/ContractInfoCard';
|
||||
import { ProgressBillingItemTable } from './tables/ProgressBillingItemTable';
|
||||
import { PhotoTable } from './tables/PhotoTable';
|
||||
import { DirectConstructionModal } from './modals/DirectConstructionModal';
|
||||
import { IndirectConstructionModal } from './modals/IndirectConstructionModal';
|
||||
import { PhotoDocumentModal } from './modals/PhotoDocumentModal';
|
||||
|
||||
interface ProgressBillingDetailFormProps {
|
||||
mode: 'view' | 'edit';
|
||||
billingId: string;
|
||||
initialData?: ProgressBillingDetail;
|
||||
}
|
||||
|
||||
export default function ProgressBillingDetailForm({
|
||||
mode,
|
||||
billingId,
|
||||
initialData,
|
||||
}: ProgressBillingDetailFormProps) {
|
||||
const {
|
||||
// Mode flags
|
||||
isViewMode,
|
||||
isEditMode,
|
||||
|
||||
// Form data
|
||||
formData,
|
||||
|
||||
// Loading state
|
||||
isLoading,
|
||||
|
||||
// Dialog states
|
||||
showSaveDialog,
|
||||
setShowSaveDialog,
|
||||
showDeleteDialog,
|
||||
setShowDeleteDialog,
|
||||
|
||||
// Modal states
|
||||
showDirectConstructionModal,
|
||||
setShowDirectConstructionModal,
|
||||
showIndirectConstructionModal,
|
||||
setShowIndirectConstructionModal,
|
||||
showPhotoDocumentModal,
|
||||
setShowPhotoDocumentModal,
|
||||
|
||||
// Selection states
|
||||
selectedBillingItems,
|
||||
selectedPhotoItems,
|
||||
|
||||
// Navigation handlers
|
||||
handleBack,
|
||||
handleEdit,
|
||||
handleCancel,
|
||||
|
||||
// Form handlers
|
||||
handleFieldChange,
|
||||
|
||||
// CRUD handlers
|
||||
handleSave,
|
||||
handleConfirmSave,
|
||||
handleDelete,
|
||||
handleConfirmDelete,
|
||||
|
||||
// Billing item handlers
|
||||
handleBillingItemChange,
|
||||
handleToggleBillingItemSelection,
|
||||
handleToggleSelectAllBillingItems,
|
||||
handleApplySelectedBillingItems,
|
||||
|
||||
// Photo item handlers
|
||||
handleTogglePhotoItemSelection,
|
||||
handleToggleSelectAllPhotoItems,
|
||||
handleApplySelectedPhotoItems,
|
||||
handlePhotoSelect,
|
||||
|
||||
// Modal handlers
|
||||
handleViewDirectConstruction,
|
||||
handleViewIndirectConstruction,
|
||||
handleViewPhotoDocument,
|
||||
} = useProgressBillingDetailForm({ mode, billingId, initialData });
|
||||
|
||||
// 헤더 액션 버튼
|
||||
const headerActions = isViewMode ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" onClick={handleBack}>
|
||||
<List className="h-4 w-4 mr-2" />
|
||||
목록
|
||||
</Button>
|
||||
<Button variant="outline" onClick={handleViewDirectConstruction}>
|
||||
<Eye className="h-4 w-4 mr-2" />
|
||||
직접 공사 내역 보기
|
||||
</Button>
|
||||
<Button variant="outline" onClick={handleViewIndirectConstruction}>
|
||||
<Eye className="h-4 w-4 mr-2" />
|
||||
간접 공사 내역 보기
|
||||
</Button>
|
||||
<Button variant="outline" onClick={handleViewPhotoDocument}>
|
||||
<Eye className="h-4 w-4 mr-2" />
|
||||
사진대지 보기
|
||||
</Button>
|
||||
<Button onClick={handleEdit}>
|
||||
<Edit className="h-4 w-4 mr-2" />
|
||||
수정
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" onClick={handleBack}>
|
||||
<List className="h-4 w-4 mr-2" />
|
||||
목록
|
||||
</Button>
|
||||
<Button variant="outline" onClick={handleCancel}>
|
||||
취소
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={isLoading}>
|
||||
저장
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
<PageHeader
|
||||
title="기성청구 상세"
|
||||
description="기성청구를 등록하고 관리합니다"
|
||||
icon={FileText}
|
||||
onBack={handleBack}
|
||||
actions={headerActions}
|
||||
/>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* 기성청구 정보 */}
|
||||
<ProgressBillingInfoCard
|
||||
formData={formData}
|
||||
isViewMode={isViewMode}
|
||||
onFieldChange={handleFieldChange}
|
||||
/>
|
||||
|
||||
{/* 계약 정보 */}
|
||||
<ContractInfoCard formData={formData} />
|
||||
|
||||
{/* 기성청구 내역 */}
|
||||
<ProgressBillingItemTable
|
||||
items={formData.billingItems}
|
||||
isViewMode={isViewMode}
|
||||
isEditMode={isEditMode}
|
||||
selectedItems={selectedBillingItems}
|
||||
onToggleSelection={handleToggleBillingItemSelection}
|
||||
onToggleSelectAll={handleToggleSelectAllBillingItems}
|
||||
onApplySelected={handleApplySelectedBillingItems}
|
||||
onItemChange={handleBillingItemChange}
|
||||
/>
|
||||
|
||||
{/* 사진대지 */}
|
||||
<PhotoTable
|
||||
items={formData.photoItems}
|
||||
isViewMode={isViewMode}
|
||||
isEditMode={isEditMode}
|
||||
selectedItems={selectedPhotoItems}
|
||||
onToggleSelection={handleTogglePhotoItemSelection}
|
||||
onToggleSelectAll={handleToggleSelectAllPhotoItems}
|
||||
onApplySelected={handleApplySelectedPhotoItems}
|
||||
onPhotoSelect={handlePhotoSelect}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 저장 확인 다이얼로그 */}
|
||||
<AlertDialog open={showSaveDialog} onOpenChange={setShowSaveDialog}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>저장 확인</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
변경사항을 저장하시겠습니까?
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>취소</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleConfirmSave} disabled={isLoading}>
|
||||
{isLoading ? '저장 중...' : '저장'}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{/* 삭제 확인 다이얼로그 */}
|
||||
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>삭제 확인</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
정말로 이 기성청구를 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>취소</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleConfirmDelete}
|
||||
disabled={isLoading}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
{isLoading ? '삭제 중...' : '삭제'}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{/* 직접 공사 내역서 모달 */}
|
||||
<DirectConstructionModal
|
||||
open={showDirectConstructionModal}
|
||||
onOpenChange={setShowDirectConstructionModal}
|
||||
data={formData}
|
||||
/>
|
||||
|
||||
{/* 간접 공사 내역서 모달 */}
|
||||
<IndirectConstructionModal
|
||||
open={showIndirectConstructionModal}
|
||||
onOpenChange={setShowIndirectConstructionModal}
|
||||
data={formData}
|
||||
/>
|
||||
|
||||
{/* 사진대지 모달 */}
|
||||
<PhotoDocumentModal
|
||||
open={showPhotoDocumentModal}
|
||||
onOpenChange={setShowPhotoDocumentModal}
|
||||
data={formData}
|
||||
/>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,440 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useMemo, useCallback, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { FileText, Pencil } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { TableCell, TableRow } from '@/components/ui/table';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
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';
|
||||
import type { ProgressBilling, ProgressBillingStats } from './types';
|
||||
import {
|
||||
PROGRESS_BILLING_STATUS_OPTIONS,
|
||||
PROGRESS_BILLING_SORT_OPTIONS,
|
||||
PROGRESS_BILLING_STATUS_STYLES,
|
||||
PROGRESS_BILLING_STATUS_LABELS,
|
||||
MOCK_PARTNERS,
|
||||
MOCK_SITES,
|
||||
PARTNER_SITES_MAP,
|
||||
} from './types';
|
||||
import {
|
||||
getProgressBillingList,
|
||||
getProgressBillingStats,
|
||||
} from './actions';
|
||||
|
||||
// 테이블 컬럼 정의
|
||||
const tableColumns: TableColumn[] = [
|
||||
{ key: 'no', label: '번호', className: 'w-[50px] text-center' },
|
||||
{ key: 'billingNumber', label: '기성청구번호', className: 'w-[140px]' },
|
||||
{ key: 'partnerName', label: '거래처', className: 'w-[120px]' },
|
||||
{ key: 'siteName', label: '현장명', className: 'min-w-[150px]' },
|
||||
{ key: 'round', label: '회차', className: 'w-[60px] text-center' },
|
||||
{ key: 'billingYearMonth', label: '기성청구연월', className: 'w-[110px] text-center' },
|
||||
{ key: 'previousBilling', label: '전회기성', className: 'w-[120px] text-right' },
|
||||
{ key: 'currentBilling', label: '금회기성', className: 'w-[120px] text-right' },
|
||||
{ key: 'cumulativeBilling', label: '누계기성', className: 'w-[120px] text-right' },
|
||||
{ key: 'status', label: '상태', className: 'w-[100px] text-center' },
|
||||
{ key: 'actions', label: '작업', className: 'w-[80px] text-center' },
|
||||
];
|
||||
|
||||
interface ProgressBillingManagementListClientProps {
|
||||
initialData?: ProgressBilling[];
|
||||
initialStats?: ProgressBillingStats;
|
||||
}
|
||||
|
||||
export default function ProgressBillingManagementListClient({
|
||||
initialData = [],
|
||||
initialStats,
|
||||
}: ProgressBillingManagementListClientProps) {
|
||||
const router = useRouter();
|
||||
|
||||
// 상태
|
||||
const [billings, setBillings] = useState<ProgressBilling[]>(initialData);
|
||||
const [stats, setStats] = useState<ProgressBillingStats | null>(initialStats || null);
|
||||
const [searchValue, setSearchValue] = useState('');
|
||||
// 다중선택 필터
|
||||
const [partnerFilters, setPartnerFilters] = useState<string[]>([]);
|
||||
const [siteFilters, setSiteFilters] = useState<string[]>([]);
|
||||
const [statusFilter, setStatusFilter] = useState<string>('all');
|
||||
const [sortBy, setSortBy] = useState<string>('latest');
|
||||
const [startDate, setStartDate] = useState<string>('');
|
||||
const [endDate, setEndDate] = useState<string>('');
|
||||
const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set());
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [activeStatTab, setActiveStatTab] = useState<'all' | 'contractWaiting' | 'contractComplete'>('all');
|
||||
const itemsPerPage = 20;
|
||||
|
||||
// 데이터 로드
|
||||
const loadData = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const [listResult, statsResult] = await Promise.all([
|
||||
getProgressBillingList({
|
||||
size: 1000,
|
||||
startDate: startDate || undefined,
|
||||
endDate: endDate || undefined,
|
||||
}),
|
||||
getProgressBillingStats(),
|
||||
]);
|
||||
|
||||
if (listResult.success && listResult.data) {
|
||||
setBillings(listResult.data.items);
|
||||
}
|
||||
if (statsResult.success && statsResult.data) {
|
||||
setStats(statsResult.data);
|
||||
}
|
||||
} catch {
|
||||
toast.error('데이터 로드에 실패했습니다.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [startDate, endDate]);
|
||||
|
||||
// 초기 데이터가 없으면 로드
|
||||
useEffect(() => {
|
||||
if (initialData.length === 0) {
|
||||
loadData();
|
||||
}
|
||||
}, [initialData.length, loadData]);
|
||||
|
||||
// 거래처 선택에 따른 현장 옵션 필터링
|
||||
const filteredSiteOptions: MultiSelectOption[] = useMemo(() => {
|
||||
if (partnerFilters.length === 0) {
|
||||
return MOCK_SITES;
|
||||
}
|
||||
|
||||
// 선택된 거래처들의 현장 ID 수집
|
||||
const availableSiteIds = new Set<string>();
|
||||
partnerFilters.forEach((partnerId) => {
|
||||
const siteIds = PARTNER_SITES_MAP[partnerId] || [];
|
||||
siteIds.forEach((siteId) => availableSiteIds.add(siteId));
|
||||
});
|
||||
|
||||
return MOCK_SITES.filter((site) => availableSiteIds.has(site.value));
|
||||
}, [partnerFilters]);
|
||||
|
||||
// 필터링된 데이터
|
||||
const filteredBillings = useMemo(() => {
|
||||
return billings.filter((billing) => {
|
||||
// 상태 탭 필터
|
||||
if (activeStatTab === 'contractWaiting' &&
|
||||
billing.status !== 'billing_waiting' &&
|
||||
billing.status !== 'approval_waiting') return false;
|
||||
if (activeStatTab === 'contractComplete' && billing.status !== 'billing_complete') return false;
|
||||
|
||||
// 상태 필터
|
||||
if (statusFilter !== 'all' && billing.status !== statusFilter) return false;
|
||||
|
||||
// 거래처 필터 (다중선택)
|
||||
if (partnerFilters.length > 0 && !partnerFilters.includes(billing.partnerId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 현장 필터 (다중선택)
|
||||
if (siteFilters.length > 0 && !siteFilters.includes(billing.siteId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 검색 필터
|
||||
if (searchValue) {
|
||||
const search = searchValue.toLowerCase();
|
||||
return (
|
||||
billing.billingNumber.toLowerCase().includes(search) ||
|
||||
billing.partnerName.toLowerCase().includes(search) ||
|
||||
billing.siteName.toLowerCase().includes(search)
|
||||
);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}, [billings, activeStatTab, statusFilter, partnerFilters, siteFilters, searchValue]);
|
||||
|
||||
// 정렬
|
||||
const sortedBillings = useMemo(() => {
|
||||
const sorted = [...filteredBillings];
|
||||
switch (sortBy) {
|
||||
case 'latest':
|
||||
sorted.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
||||
break;
|
||||
case 'oldest':
|
||||
sorted.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
|
||||
break;
|
||||
case 'partnerNameAsc':
|
||||
sorted.sort((a, b) => a.partnerName.localeCompare(b.partnerName, 'ko'));
|
||||
break;
|
||||
case 'partnerNameDesc':
|
||||
sorted.sort((a, b) => b.partnerName.localeCompare(a.partnerName, 'ko'));
|
||||
break;
|
||||
case 'siteNameAsc':
|
||||
sorted.sort((a, b) => a.siteName.localeCompare(b.siteName, 'ko'));
|
||||
break;
|
||||
case 'siteNameDesc':
|
||||
sorted.sort((a, b) => b.siteName.localeCompare(a.siteName, 'ko'));
|
||||
break;
|
||||
}
|
||||
return sorted;
|
||||
}, [filteredBillings, sortBy]);
|
||||
|
||||
// 페이지네이션
|
||||
const totalPages = Math.ceil(sortedBillings.length / itemsPerPage);
|
||||
const paginatedData = useMemo(() => {
|
||||
const start = (currentPage - 1) * itemsPerPage;
|
||||
return sortedBillings.slice(start, start + itemsPerPage);
|
||||
}, [sortedBillings, currentPage, itemsPerPage]);
|
||||
|
||||
// 핸들러
|
||||
const handleSearchChange = useCallback((value: string) => {
|
||||
setSearchValue(value);
|
||||
setCurrentPage(1);
|
||||
}, []);
|
||||
|
||||
const handleToggleSelection = useCallback((id: string) => {
|
||||
setSelectedItems((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(id)) {
|
||||
newSet.delete(id);
|
||||
} else {
|
||||
newSet.add(id);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleToggleSelectAll = useCallback(() => {
|
||||
if (selectedItems.size === paginatedData.length) {
|
||||
setSelectedItems(new Set());
|
||||
} else {
|
||||
setSelectedItems(new Set(paginatedData.map((b) => b.id)));
|
||||
}
|
||||
}, [selectedItems.size, paginatedData]);
|
||||
|
||||
const handleRowClick = useCallback(
|
||||
(billing: ProgressBilling) => {
|
||||
router.push(`/ko/construction/billing/progress-billing-management/${billing.id}`);
|
||||
},
|
||||
[router]
|
||||
);
|
||||
|
||||
const handleEdit = useCallback(
|
||||
(e: React.MouseEvent, billingId: string) => {
|
||||
e.stopPropagation();
|
||||
router.push(`/ko/construction/billing/progress-billing-management/${billingId}/edit`);
|
||||
},
|
||||
[router]
|
||||
);
|
||||
|
||||
// 금액 포맷
|
||||
const formatCurrency = (amount: number) => {
|
||||
return new Intl.NumberFormat('ko-KR').format(amount);
|
||||
};
|
||||
|
||||
// 테이블 행 렌더링
|
||||
const renderTableRow = useCallback(
|
||||
(billing: ProgressBilling, index: number, globalIndex: number) => {
|
||||
const isSelected = selectedItems.has(billing.id);
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
key={billing.id}
|
||||
className="cursor-pointer hover:bg-muted/50"
|
||||
onClick={() => handleRowClick(billing)}
|
||||
>
|
||||
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={() => handleToggleSelection(billing.id)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-center text-muted-foreground">{globalIndex}</TableCell>
|
||||
<TableCell>{billing.billingNumber}</TableCell>
|
||||
<TableCell>{billing.partnerName}</TableCell>
|
||||
<TableCell>{billing.siteName}</TableCell>
|
||||
<TableCell className="text-center">{billing.round}차</TableCell>
|
||||
<TableCell className="text-center">{billing.billingYearMonth}</TableCell>
|
||||
<TableCell className="text-right">{formatCurrency(billing.previousBilling)}</TableCell>
|
||||
<TableCell className="text-right">{formatCurrency(billing.currentBilling)}</TableCell>
|
||||
<TableCell className="text-right">{formatCurrency(billing.cumulativeBilling)}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<span className={`px-2 py-1 rounded-full text-xs font-medium ${PROGRESS_BILLING_STATUS_STYLES[billing.status]}`}>
|
||||
{PROGRESS_BILLING_STATUS_LABELS[billing.status]}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{isSelected && (
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={(e) => handleEdit(e, billing.id)}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
},
|
||||
[selectedItems, handleToggleSelection, handleRowClick, handleEdit]
|
||||
);
|
||||
|
||||
// 모바일 카드 렌더링
|
||||
const renderMobileCard = useCallback(
|
||||
(billing: ProgressBilling, index: number, globalIndex: number, isSelected: boolean, onToggle: () => void) => {
|
||||
return (
|
||||
<MobileCard
|
||||
title={billing.siteName}
|
||||
subtitle={billing.billingNumber}
|
||||
badge={PROGRESS_BILLING_STATUS_LABELS[billing.status]}
|
||||
badgeVariant="secondary"
|
||||
isSelected={isSelected}
|
||||
onToggle={onToggle}
|
||||
onClick={() => handleRowClick(billing)}
|
||||
details={[
|
||||
{ label: '거래처', value: billing.partnerName },
|
||||
{ label: '회차', value: `${billing.round}차` },
|
||||
{ label: '금회기성', value: formatCurrency(billing.currentBilling) },
|
||||
]}
|
||||
/>
|
||||
);
|
||||
},
|
||||
[handleRowClick]
|
||||
);
|
||||
|
||||
// 헤더 액션 (날짜 범위 + 퀵버튼)
|
||||
const headerActions = (
|
||||
<DateRangeSelector
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
onStartDateChange={setStartDate}
|
||||
onEndDateChange={setEndDate}
|
||||
showQuickButtons={true}
|
||||
/>
|
||||
);
|
||||
|
||||
// Stats 카드 데이터
|
||||
const statsCardsData: StatCard[] = [
|
||||
{
|
||||
label: '전체 계약',
|
||||
value: stats?.total ?? 0,
|
||||
icon: FileText,
|
||||
iconColor: 'text-blue-600',
|
||||
onClick: () => setActiveStatTab('all'),
|
||||
isActive: activeStatTab === 'all',
|
||||
},
|
||||
{
|
||||
label: '계약대기',
|
||||
value: stats?.contractWaiting ?? 0,
|
||||
icon: FileText,
|
||||
iconColor: 'text-yellow-600',
|
||||
onClick: () => setActiveStatTab('contractWaiting'),
|
||||
isActive: activeStatTab === 'contractWaiting',
|
||||
},
|
||||
{
|
||||
label: '계약완료',
|
||||
value: stats?.contractComplete ?? 0,
|
||||
icon: FileText,
|
||||
iconColor: 'text-green-600',
|
||||
onClick: () => setActiveStatTab('contractComplete'),
|
||||
isActive: activeStatTab === 'contractComplete',
|
||||
},
|
||||
];
|
||||
|
||||
// 필터 옵션들
|
||||
const partnerOptions: MultiSelectOption[] = useMemo(() => MOCK_PARTNERS, []);
|
||||
|
||||
// filterConfig 기반 통합 필터 시스템
|
||||
const filterConfig: FilterFieldConfig[] = useMemo(() => [
|
||||
{ key: 'partners', label: '거래처', type: 'multi', options: partnerOptions },
|
||||
{ key: 'sites', label: '현장명', type: 'multi', options: filteredSiteOptions },
|
||||
{ key: 'status', label: '상태', type: 'single', options: PROGRESS_BILLING_STATUS_OPTIONS.filter(opt => opt.value !== 'all') },
|
||||
{ key: 'sortBy', label: '정렬', type: 'single', options: PROGRESS_BILLING_SORT_OPTIONS.map(opt => ({ value: opt.value, label: opt.label })), allOptionLabel: '최신순' },
|
||||
], [partnerOptions, filteredSiteOptions]);
|
||||
|
||||
// filterValues 객체
|
||||
const filterValues: FilterValues = useMemo(() => ({
|
||||
partners: partnerFilters,
|
||||
sites: siteFilters,
|
||||
status: statusFilter,
|
||||
sortBy: sortBy,
|
||||
}), [partnerFilters, siteFilters, statusFilter, sortBy]);
|
||||
|
||||
// 필터 변경 핸들러
|
||||
const handleFilterChange = useCallback((key: string, value: string | string[]) => {
|
||||
switch (key) {
|
||||
case 'partners':
|
||||
setPartnerFilters(value as string[]);
|
||||
// 거래처 변경 시 현장 필터 초기화
|
||||
setSiteFilters([]);
|
||||
break;
|
||||
case 'sites':
|
||||
setSiteFilters(value as string[]);
|
||||
break;
|
||||
case 'status':
|
||||
setStatusFilter(value as string);
|
||||
break;
|
||||
case 'sortBy':
|
||||
setSortBy(value as string);
|
||||
break;
|
||||
}
|
||||
setCurrentPage(1);
|
||||
}, []);
|
||||
|
||||
// 필터 초기화 핸들러
|
||||
const handleFilterReset = useCallback(() => {
|
||||
setPartnerFilters([]);
|
||||
setSiteFilters([]);
|
||||
setStatusFilter('all');
|
||||
setSortBy('latest');
|
||||
setCurrentPage(1);
|
||||
}, []);
|
||||
|
||||
// 테이블 헤더 추가 액션
|
||||
const tableHeaderActions = (
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-sm text-muted-foreground whitespace-nowrap">
|
||||
총 {sortedBillings.length}건
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<IntegratedListTemplateV2
|
||||
title="기성청구관리"
|
||||
description="기성청구를 등록하고 관리합니다."
|
||||
icon={FileText}
|
||||
headerActions={headerActions}
|
||||
stats={statsCardsData}
|
||||
filterConfig={filterConfig}
|
||||
filterValues={filterValues}
|
||||
onFilterChange={handleFilterChange}
|
||||
onFilterReset={handleFilterReset}
|
||||
filterTitle="기성청구 필터"
|
||||
tableHeaderActions={tableHeaderActions}
|
||||
searchValue={searchValue}
|
||||
onSearchChange={handleSearchChange}
|
||||
searchPlaceholder="기성청구번호, 거래처, 현장명 검색"
|
||||
tableColumns={tableColumns}
|
||||
data={paginatedData}
|
||||
allData={sortedBillings}
|
||||
getItemId={(item) => item.id}
|
||||
renderTableRow={renderTableRow}
|
||||
renderMobileCard={renderMobileCard}
|
||||
selectedItems={selectedItems}
|
||||
onToggleSelection={handleToggleSelection}
|
||||
onToggleSelectAll={handleToggleSelectAll}
|
||||
pagination={{
|
||||
currentPage,
|
||||
totalPages,
|
||||
totalItems: sortedBillings.length,
|
||||
itemsPerPage,
|
||||
onPageChange: setCurrentPage,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
317
src/components/business/construction/progress-billing/actions.ts
Normal file
317
src/components/business/construction/progress-billing/actions.ts
Normal file
@@ -0,0 +1,317 @@
|
||||
'use server';
|
||||
|
||||
import type {
|
||||
ProgressBilling,
|
||||
ProgressBillingStats,
|
||||
ProgressBillingStatus,
|
||||
ProgressBillingDetail,
|
||||
ProgressBillingDetailFormData,
|
||||
} from './types';
|
||||
import { MOCK_PROGRESS_BILLING_DETAIL } from './types';
|
||||
import { format, subMonths } from 'date-fns';
|
||||
|
||||
/**
|
||||
* 목업 기성청구 데이터 생성
|
||||
*/
|
||||
function generateMockProgressBillings(): ProgressBilling[] {
|
||||
const partners = [
|
||||
{ id: '1', name: '(주)대한건설' },
|
||||
{ id: '2', name: '삼성물산' },
|
||||
{ id: '3', name: '현대건설' },
|
||||
{ id: '4', name: 'GS건설' },
|
||||
{ id: '5', name: '대림산업' },
|
||||
];
|
||||
|
||||
const sites = [
|
||||
{ id: '1', name: '강남 오피스빌딩 신축' },
|
||||
{ id: '2', name: '판교 데이터센터' },
|
||||
{ id: '3', name: '송도 물류센터' },
|
||||
{ id: '4', name: '인천공항 터미널' },
|
||||
{ id: '5', name: '부산항 창고' },
|
||||
];
|
||||
|
||||
const statuses: ProgressBillingStatus[] = ['billing_waiting', 'approval_waiting', 'constructor_sent', 'billing_complete'];
|
||||
|
||||
const billings: ProgressBilling[] = [];
|
||||
const baseDate = new Date(2026, 0, 1);
|
||||
|
||||
for (let i = 0; i < 50; i++) {
|
||||
const partner = partners[i % partners.length];
|
||||
const site = sites[i % sites.length];
|
||||
const status = statuses[i % statuses.length];
|
||||
const round = (i % 12) + 1;
|
||||
const monthOffset = i % 6;
|
||||
const billingDate = subMonths(baseDate, monthOffset);
|
||||
|
||||
// 기성 금액 계산 (회차에 따라 누적)
|
||||
const baseAmount = 10000000 + (i * 500000);
|
||||
const previousBilling = round > 1 ? baseAmount * (round - 1) : 0;
|
||||
const currentBilling = baseAmount;
|
||||
const cumulativeBilling = previousBilling + currentBilling;
|
||||
|
||||
billings.push({
|
||||
id: `billing-${i + 1}`,
|
||||
billingNumber: `PB-${2026}-${String(i + 1).padStart(4, '0')}`,
|
||||
partnerId: partner.id,
|
||||
partnerName: partner.name,
|
||||
siteId: site.id,
|
||||
siteName: site.name,
|
||||
round,
|
||||
billingYearMonth: format(billingDate, 'yyyy-MM'),
|
||||
previousBilling,
|
||||
currentBilling,
|
||||
cumulativeBilling,
|
||||
status,
|
||||
createdAt: format(billingDate, "yyyy-MM-dd'T'HH:mm:ss"),
|
||||
updatedAt: format(baseDate, "yyyy-MM-dd'T'HH:mm:ss"),
|
||||
});
|
||||
}
|
||||
|
||||
return billings;
|
||||
}
|
||||
|
||||
// 캐시된 목업 데이터
|
||||
let cachedBillings: ProgressBilling[] | null = null;
|
||||
|
||||
function getMockBillings(): ProgressBilling[] {
|
||||
if (!cachedBillings) {
|
||||
cachedBillings = generateMockProgressBillings();
|
||||
}
|
||||
return cachedBillings;
|
||||
}
|
||||
|
||||
/**
|
||||
* 기성청구 목록 조회
|
||||
*/
|
||||
export async function getProgressBillingList(params?: {
|
||||
size?: number;
|
||||
page?: number;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
status?: string;
|
||||
partnerIds?: string[];
|
||||
siteIds?: string[];
|
||||
search?: string;
|
||||
}): Promise<{
|
||||
success: boolean;
|
||||
data?: { items: ProgressBilling[]; total: number };
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
let billings = getMockBillings();
|
||||
|
||||
// 날짜 필터
|
||||
if (params?.startDate && params?.endDate) {
|
||||
billings = billings.filter((billing) => {
|
||||
const billingDate = billing.billingYearMonth;
|
||||
return billingDate >= params.startDate!.slice(0, 7) && billingDate <= params.endDate!.slice(0, 7);
|
||||
});
|
||||
}
|
||||
|
||||
// 상태 필터
|
||||
if (params?.status && params.status !== 'all') {
|
||||
billings = billings.filter((billing) => billing.status === params.status);
|
||||
}
|
||||
|
||||
// 거래처 필터 (다중선택)
|
||||
if (params?.partnerIds && params.partnerIds.length > 0) {
|
||||
billings = billings.filter((billing) => params.partnerIds!.includes(billing.partnerId));
|
||||
}
|
||||
|
||||
// 현장 필터 (다중선택)
|
||||
if (params?.siteIds && params.siteIds.length > 0) {
|
||||
billings = billings.filter((billing) => params.siteIds!.includes(billing.siteId));
|
||||
}
|
||||
|
||||
// 검색
|
||||
if (params?.search) {
|
||||
const search = params.search.toLowerCase();
|
||||
billings = billings.filter(
|
||||
(billing) =>
|
||||
billing.billingNumber.toLowerCase().includes(search) ||
|
||||
billing.partnerName.toLowerCase().includes(search) ||
|
||||
billing.siteName.toLowerCase().includes(search)
|
||||
);
|
||||
}
|
||||
|
||||
// 페이지네이션
|
||||
const page = params?.page || 1;
|
||||
const size = params?.size || 1000;
|
||||
const start = (page - 1) * size;
|
||||
const paginatedBillings = billings.slice(start, start + size);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
items: paginatedBillings,
|
||||
total: billings.length,
|
||||
},
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
success: false,
|
||||
error: '기성청구 목록 조회에 실패했습니다.',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 기성청구 통계 조회
|
||||
*/
|
||||
export async function getProgressBillingStats(): Promise<{
|
||||
success: boolean;
|
||||
data?: ProgressBillingStats;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
const billings = getMockBillings();
|
||||
|
||||
const stats: ProgressBillingStats = {
|
||||
total: billings.length,
|
||||
contractWaiting: billings.filter((b) => b.status === 'billing_waiting' || b.status === 'approval_waiting').length,
|
||||
contractComplete: billings.filter((b) => b.status === 'billing_complete').length,
|
||||
};
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: stats,
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
success: false,
|
||||
error: '기성청구 통계 조회에 실패했습니다.',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 기성청구 상세 조회
|
||||
*/
|
||||
export async function getProgressBillingDetail(id: string): Promise<{
|
||||
success: boolean;
|
||||
data?: ProgressBillingDetail;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
// TODO: 실제 API 호출로 대체
|
||||
// const response = await apiClient.get(`/progress-billing/${id}`);
|
||||
|
||||
// 목업 데이터 반환
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
...MOCK_PROGRESS_BILLING_DETAIL,
|
||||
id,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch progress billing detail:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: '기성청구 정보를 불러오는데 실패했습니다.',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 기성청구 저장 (생성/수정)
|
||||
*/
|
||||
export async function saveProgressBilling(
|
||||
id: string | null,
|
||||
data: ProgressBillingDetailFormData
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
data?: ProgressBillingDetail;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
// TODO: 실제 API 호출로 대체
|
||||
// const response = id
|
||||
// ? await apiClient.put(`/progress-billing/${id}`, data)
|
||||
// : await apiClient.post('/progress-billing', data);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
|
||||
console.log('Save progress billing:', { id, data });
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
...MOCK_PROGRESS_BILLING_DETAIL,
|
||||
id: id || String(Date.now()),
|
||||
...data,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to save progress billing:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: '기성청구 저장에 실패했습니다.',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 기성청구 삭제
|
||||
*/
|
||||
export async function deleteProgressBilling(id: string): Promise<{
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
// TODO: 실제 API 호출로 대체
|
||||
// await apiClient.delete(`/progress-billing/${id}`);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
|
||||
console.log('Delete progress billing:', id);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to delete progress billing:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: '기성청구 삭제에 실패했습니다.',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 기성청구 상태 변경
|
||||
*/
|
||||
export async function updateProgressBillingStatus(
|
||||
id: string,
|
||||
status: string
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
// TODO: 실제 API 호출로 대체
|
||||
// await apiClient.patch(`/progress-billing/${id}/status`, { status });
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
|
||||
console.log('Update progress billing status:', { id, status });
|
||||
|
||||
// 기성청구완료 시 매출 자동 등록 로직
|
||||
if (status === 'completed') {
|
||||
console.log('Auto-register sales for completed billing:', id);
|
||||
// TODO: 매출 자동 등록 API 호출
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to update progress billing status:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: '상태 변경에 실패했습니다.',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
'use client';
|
||||
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import type { ProgressBillingDetailFormData } from '../types';
|
||||
|
||||
interface ContractInfoCardProps {
|
||||
formData: ProgressBillingDetailFormData;
|
||||
}
|
||||
|
||||
export function ContractInfoCard({ formData }: ContractInfoCardProps) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">계약 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{/* 거래처명 */}
|
||||
<div className="space-y-2">
|
||||
<Label>거래처명</Label>
|
||||
<Input value={formData.partnerName} placeholder="회사명" disabled className="bg-muted" />
|
||||
</div>
|
||||
|
||||
{/* 현장명 */}
|
||||
<div className="space-y-2">
|
||||
<Label>현장명</Label>
|
||||
<Input value={formData.siteName} disabled className="bg-muted" />
|
||||
</div>
|
||||
|
||||
{/* 계약번호 */}
|
||||
<div className="space-y-2">
|
||||
<Label>계약번호</Label>
|
||||
<Input value={formData.contractNumber} disabled className="bg-muted" />
|
||||
</div>
|
||||
|
||||
{/* 공사PM */}
|
||||
<div className="space-y-2">
|
||||
<Label>공사PM</Label>
|
||||
<Input value={formData.constructionPM} disabled className="bg-muted" />
|
||||
</div>
|
||||
|
||||
{/* 공사담당자 */}
|
||||
<div className="space-y-2 md:col-span-2">
|
||||
<Label>공사담당자</Label>
|
||||
<Input
|
||||
value={formData.constructionManagers.join(', ')}
|
||||
disabled
|
||||
className="bg-muted"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
'use client';
|
||||
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import type { ProgressBillingDetailFormData, ProgressBillingStatus } from '../types';
|
||||
import { PROGRESS_BILLING_STATUS_OPTIONS } from '../types';
|
||||
|
||||
interface ProgressBillingInfoCardProps {
|
||||
formData: ProgressBillingDetailFormData;
|
||||
isViewMode: boolean;
|
||||
onFieldChange: (field: keyof ProgressBillingDetailFormData, value: string | number) => void;
|
||||
}
|
||||
|
||||
export function ProgressBillingInfoCard({
|
||||
formData,
|
||||
isViewMode,
|
||||
onFieldChange,
|
||||
}: ProgressBillingInfoCardProps) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">기성청구 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{/* 기성청구번호 */}
|
||||
<div className="space-y-2">
|
||||
<Label>기성청구번호</Label>
|
||||
<Input value={formData.billingNumber} disabled className="bg-muted" />
|
||||
</div>
|
||||
|
||||
{/* 회차 */}
|
||||
<div className="space-y-2">
|
||||
<Label>회차</Label>
|
||||
<Input value={`${formData.billingRound}회차`} disabled className="bg-muted" />
|
||||
</div>
|
||||
|
||||
{/* 기성청구연월 */}
|
||||
<div className="space-y-2">
|
||||
<Label>기성청구연월</Label>
|
||||
<Input value={formData.billingYearMonth} disabled className="bg-muted" />
|
||||
</div>
|
||||
|
||||
{/* 상태 */}
|
||||
<div className="space-y-2">
|
||||
<Label>상태</Label>
|
||||
<Select
|
||||
key={`status-${formData.status}`}
|
||||
value={formData.status}
|
||||
onValueChange={(value) => onFieldChange('status', value as ProgressBillingStatus)}
|
||||
disabled={isViewMode}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="상태 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{PROGRESS_BILLING_STATUS_OPTIONS.filter((o) => o.value !== 'all').map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import type {
|
||||
ProgressBillingDetail,
|
||||
ProgressBillingDetailFormData,
|
||||
ProgressBillingItem,
|
||||
} from '../types';
|
||||
import {
|
||||
progressBillingDetailToFormData,
|
||||
getEmptyProgressBillingDetailFormData,
|
||||
MOCK_PROGRESS_BILLING_DETAIL,
|
||||
} from '../types';
|
||||
|
||||
interface UseProgressBillingDetailFormProps {
|
||||
mode: 'view' | 'edit';
|
||||
billingId: string;
|
||||
initialData?: ProgressBillingDetail;
|
||||
}
|
||||
|
||||
export function useProgressBillingDetailForm({
|
||||
mode,
|
||||
billingId,
|
||||
initialData,
|
||||
}: UseProgressBillingDetailFormProps) {
|
||||
const router = useRouter();
|
||||
|
||||
// Mode flags
|
||||
const isViewMode = mode === 'view';
|
||||
const isEditMode = mode === 'edit';
|
||||
|
||||
// Form data state
|
||||
const [formData, setFormData] = useState<ProgressBillingDetailFormData>(() => {
|
||||
if (initialData) {
|
||||
return progressBillingDetailToFormData(initialData);
|
||||
}
|
||||
// 목업 데이터 사용
|
||||
return progressBillingDetailToFormData(MOCK_PROGRESS_BILLING_DETAIL);
|
||||
});
|
||||
|
||||
// Loading state
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
// Dialog states
|
||||
const [showSaveDialog, setShowSaveDialog] = useState(false);
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||
|
||||
// Selection states for billing items
|
||||
const [selectedBillingItems, setSelectedBillingItems] = useState<Set<string>>(new Set());
|
||||
|
||||
// Selection states for photo items
|
||||
const [selectedPhotoItems, setSelectedPhotoItems] = useState<Set<string>>(new Set());
|
||||
|
||||
// Modal states
|
||||
const [showDirectConstructionModal, setShowDirectConstructionModal] = useState(false);
|
||||
const [showIndirectConstructionModal, setShowIndirectConstructionModal] = useState(false);
|
||||
const [showPhotoDocumentModal, setShowPhotoDocumentModal] = useState(false);
|
||||
|
||||
// Navigation handlers
|
||||
const handleBack = useCallback(() => {
|
||||
router.push('/construction/billing/progress-billing-management');
|
||||
}, [router]);
|
||||
|
||||
const handleEdit = useCallback(() => {
|
||||
router.push('/construction/billing/progress-billing-management/' + billingId + '/edit');
|
||||
}, [router, billingId]);
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
router.push('/construction/billing/progress-billing-management/' + billingId);
|
||||
}, [router, billingId]);
|
||||
|
||||
// Form handlers
|
||||
const handleFieldChange = useCallback(
|
||||
(field: keyof ProgressBillingDetailFormData, value: string | number) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
[field]: value,
|
||||
}));
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
// Save handlers
|
||||
const handleSave = useCallback(() => {
|
||||
setShowSaveDialog(true);
|
||||
}, []);
|
||||
|
||||
const handleConfirmSave = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
// TODO: API 호출
|
||||
console.log('Save billing data:', formData);
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
setShowSaveDialog(false);
|
||||
router.push('/construction/billing/progress-billing-management/' + billingId);
|
||||
} catch (error) {
|
||||
console.error('Save failed:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [formData, router, billingId]);
|
||||
|
||||
// Delete handlers
|
||||
const handleDelete = useCallback(() => {
|
||||
setShowDeleteDialog(true);
|
||||
}, []);
|
||||
|
||||
const handleConfirmDelete = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
// TODO: API 호출
|
||||
console.log('Delete billing:', billingId);
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
setShowDeleteDialog(false);
|
||||
router.push('/construction/billing/progress-billing-management');
|
||||
} catch (error) {
|
||||
console.error('Delete failed:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [router, billingId]);
|
||||
|
||||
// Billing item handlers
|
||||
const handleBillingItemChange = useCallback(
|
||||
(itemId: string, field: keyof ProgressBillingItem, value: string | number) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
billingItems: prev.billingItems.map((item) =>
|
||||
item.id === itemId ? { ...item, [field]: value } : item
|
||||
),
|
||||
}));
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleToggleBillingItemSelection = useCallback((itemId: string) => {
|
||||
setSelectedBillingItems((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(itemId)) {
|
||||
newSet.delete(itemId);
|
||||
} else {
|
||||
newSet.add(itemId);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleToggleSelectAllBillingItems = useCallback(() => {
|
||||
setSelectedBillingItems((prev) => {
|
||||
if (prev.size === formData.billingItems.length) {
|
||||
return new Set();
|
||||
}
|
||||
return new Set(formData.billingItems.map((item) => item.id));
|
||||
});
|
||||
}, [formData.billingItems]);
|
||||
|
||||
const handleApplySelectedBillingItems = useCallback(() => {
|
||||
// 선택된 항목의 변경사항 적용 (현재는 선택 해제만)
|
||||
setSelectedBillingItems(new Set());
|
||||
}, []);
|
||||
|
||||
// Photo item handlers
|
||||
const handleTogglePhotoItemSelection = useCallback((itemId: string) => {
|
||||
setSelectedPhotoItems((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(itemId)) {
|
||||
newSet.delete(itemId);
|
||||
} else {
|
||||
newSet.add(itemId);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleToggleSelectAllPhotoItems = useCallback(() => {
|
||||
setSelectedPhotoItems((prev) => {
|
||||
if (prev.size === formData.photoItems.length) {
|
||||
return new Set();
|
||||
}
|
||||
return new Set(formData.photoItems.map((item) => item.id));
|
||||
});
|
||||
}, [formData.photoItems]);
|
||||
|
||||
const handleApplySelectedPhotoItems = useCallback(() => {
|
||||
// 선택된 항목의 변경사항 적용 (현재는 선택 해제만)
|
||||
setSelectedPhotoItems(new Set());
|
||||
}, []);
|
||||
|
||||
// Photo select handler (라디오 버튼으로 사진 선택)
|
||||
const handlePhotoSelect = useCallback((itemId: string, photoIndex: number) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
photoItems: prev.photoItems.map((item) =>
|
||||
item.id === itemId ? { ...item, selectedPhotoIndex: photoIndex } : item
|
||||
),
|
||||
}));
|
||||
}, []);
|
||||
|
||||
// Modal handlers
|
||||
const handleViewDirectConstruction = useCallback(() => {
|
||||
setShowDirectConstructionModal(true);
|
||||
}, []);
|
||||
|
||||
const handleViewIndirectConstruction = useCallback(() => {
|
||||
setShowIndirectConstructionModal(true);
|
||||
}, []);
|
||||
|
||||
const handleViewPhotoDocument = useCallback(() => {
|
||||
setShowPhotoDocumentModal(true);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
// Mode flags
|
||||
isViewMode,
|
||||
isEditMode,
|
||||
|
||||
// Form data
|
||||
formData,
|
||||
|
||||
// Loading state
|
||||
isLoading,
|
||||
|
||||
// Dialog states
|
||||
showSaveDialog,
|
||||
setShowSaveDialog,
|
||||
showDeleteDialog,
|
||||
setShowDeleteDialog,
|
||||
|
||||
// Modal states
|
||||
showDirectConstructionModal,
|
||||
setShowDirectConstructionModal,
|
||||
showIndirectConstructionModal,
|
||||
setShowIndirectConstructionModal,
|
||||
showPhotoDocumentModal,
|
||||
setShowPhotoDocumentModal,
|
||||
|
||||
// Selection states
|
||||
selectedBillingItems,
|
||||
selectedPhotoItems,
|
||||
|
||||
// Navigation handlers
|
||||
handleBack,
|
||||
handleEdit,
|
||||
handleCancel,
|
||||
|
||||
// Form handlers
|
||||
handleFieldChange,
|
||||
|
||||
// CRUD handlers
|
||||
handleSave,
|
||||
handleConfirmSave,
|
||||
handleDelete,
|
||||
handleConfirmDelete,
|
||||
|
||||
// Billing item handlers
|
||||
handleBillingItemChange,
|
||||
handleToggleBillingItemSelection,
|
||||
handleToggleSelectAllBillingItems,
|
||||
handleApplySelectedBillingItems,
|
||||
|
||||
// Photo item handlers
|
||||
handleTogglePhotoItemSelection,
|
||||
handleToggleSelectAllPhotoItems,
|
||||
handleApplySelectedPhotoItems,
|
||||
handlePhotoSelect,
|
||||
|
||||
// Modal handlers
|
||||
handleViewDirectConstruction,
|
||||
handleViewIndirectConstruction,
|
||||
handleViewPhotoDocument,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export { default as ProgressBillingDetailForm } from './ProgressBillingDetailForm';
|
||||
export * from './types';
|
||||
export * from './actions';
|
||||
@@ -0,0 +1,268 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
VisuallyHidden,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Edit, Trash2, Printer, X } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { printArea } from '@/lib/print-utils';
|
||||
import type { ProgressBillingDetailFormData } from '../types';
|
||||
|
||||
// 숫자 포맷팅 (천단위 콤마)
|
||||
function formatNumber(num: number | undefined): string {
|
||||
if (num === undefined || num === null) return '-';
|
||||
return num.toLocaleString('ko-KR');
|
||||
}
|
||||
|
||||
interface DirectConstructionModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
data: ProgressBillingDetailFormData;
|
||||
}
|
||||
|
||||
// 직접 공사 내역 아이템 타입
|
||||
interface DirectConstructionItem {
|
||||
id: string;
|
||||
name: string;
|
||||
product: string;
|
||||
width: number;
|
||||
height: number;
|
||||
quantity: number;
|
||||
unit: string;
|
||||
contractUnitPrice: number;
|
||||
contractAmount: number;
|
||||
prevQuantity: number;
|
||||
prevAmount: number;
|
||||
currentQuantity: number;
|
||||
currentAmount: number;
|
||||
cumulativeQuantity: number;
|
||||
cumulativeAmount: number;
|
||||
remark?: string;
|
||||
}
|
||||
|
||||
// 목업 데이터 생성
|
||||
function generateMockItems(billingItems: ProgressBillingDetailFormData['billingItems']): DirectConstructionItem[] {
|
||||
return billingItems.map((item, index) => ({
|
||||
id: item.id,
|
||||
name: item.name || '명칭',
|
||||
product: item.product || '제품명',
|
||||
width: item.width || 2500,
|
||||
height: item.height || 3200,
|
||||
quantity: 1,
|
||||
unit: 'EA',
|
||||
contractUnitPrice: 2500000,
|
||||
contractAmount: 2500000,
|
||||
prevQuantity: index < 4 ? 0 : 0.8,
|
||||
prevAmount: index < 4 ? 0 : 1900000,
|
||||
currentQuantity: 0.8,
|
||||
currentAmount: 1900000,
|
||||
cumulativeQuantity: 0.8,
|
||||
cumulativeAmount: 1900000,
|
||||
remark: '',
|
||||
}));
|
||||
}
|
||||
|
||||
export function DirectConstructionModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
data,
|
||||
}: DirectConstructionModalProps) {
|
||||
// 핸들러
|
||||
const handleEdit = () => {
|
||||
toast.info('수정 기능은 준비 중입니다.');
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
toast.info('삭제 기능은 준비 중입니다.');
|
||||
};
|
||||
|
||||
const handlePrint = () => {
|
||||
printArea({ title: '직접 공사 내역서 인쇄' });
|
||||
};
|
||||
|
||||
// 목업 데이터
|
||||
const items = generateMockItems(data.billingItems);
|
||||
|
||||
// 합계 계산
|
||||
const totalContractAmount = items.reduce((sum, item) => sum + item.contractAmount, 0);
|
||||
const totalCurrentAmount = items.reduce((sum, item) => sum + item.currentAmount, 0);
|
||||
const totalCumulativeAmount = items.reduce((sum, item) => sum + item.cumulativeAmount, 0);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-[95vw] md:max-w-[1000px] lg:max-w-[1200px] h-[95vh] p-0 flex flex-col gap-0 overflow-hidden [&>button]:hidden">
|
||||
<VisuallyHidden>
|
||||
<DialogTitle>직접 공사 내역서</DialogTitle>
|
||||
</VisuallyHidden>
|
||||
|
||||
{/* 헤더 영역 */}
|
||||
<div className="print-hidden flex-shrink-0 flex items-center justify-between px-6 py-4 border-b bg-white">
|
||||
<h2 className="text-lg font-semibold">직접 공사 내역서</h2>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => onOpenChange(false)}
|
||||
className="h-8 w-8"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 버튼 영역 */}
|
||||
<div className="print-hidden flex-shrink-0 flex items-center gap-2 px-6 py-3 bg-muted/30 border-b">
|
||||
<Button variant="outline" size="sm" onClick={handleEdit}>
|
||||
<Edit className="h-4 w-4 mr-1" />
|
||||
수정
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={handleDelete}>
|
||||
<Trash2 className="h-4 w-4 mr-1" />
|
||||
삭제
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={handlePrint}>
|
||||
<Printer className="h-4 w-4 mr-1" />
|
||||
인쇄
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 문서 영역 */}
|
||||
<div className="print-area flex-1 overflow-y-auto bg-gray-100 p-6">
|
||||
<div className="max-w-[297mm] mx-auto bg-white shadow-lg rounded-lg p-8">
|
||||
{/* 상단: 제목 + 결재란 */}
|
||||
<div className="flex items-start justify-between mb-6">
|
||||
{/* 좌측: 제목 및 문서 정보 */}
|
||||
<div className="flex-1">
|
||||
<h1 className="text-2xl font-bold mb-2">직접 공사 내역서</h1>
|
||||
<div className="text-sm text-gray-600">
|
||||
문서번호: {data.billingNumber || 'ABC123'} | 작성일자: 2025년 11월 11일
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 우측: 결재란 */}
|
||||
<table className="border-collapse border border-gray-400 text-sm">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th rowSpan={3} className="border border-gray-400 px-3 py-1 bg-gray-50 text-center align-middle w-12">
|
||||
결<br />재
|
||||
</th>
|
||||
<th className="border border-gray-400 px-6 py-1 bg-gray-50 text-center whitespace-nowrap">작성</th>
|
||||
<th className="border border-gray-400 px-6 py-1 bg-gray-50 text-center whitespace-nowrap">승인</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="border border-gray-400 px-6 py-2 text-center h-10 whitespace-nowrap">홍길동</td>
|
||||
<td className="border border-gray-400 px-6 py-2 text-center h-10 whitespace-nowrap">이름</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="border border-gray-400 px-6 py-1 text-center text-xs text-gray-500 whitespace-nowrap">부서명</td>
|
||||
<td className="border border-gray-400 px-6 py-1 text-center text-xs text-gray-500 whitespace-nowrap">부서명</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* 기성내역 제목 */}
|
||||
<div className="text-center font-bold text-lg mb-4">
|
||||
기성내역 제{data.billingRound || 1}회 ({data.billingYearMonth || '2025년 11월'})
|
||||
</div>
|
||||
|
||||
{/* 현장 정보 */}
|
||||
<div className="mb-4">
|
||||
<span className="font-bold">■ 현장: {data.siteName || '현장명'}</span>
|
||||
</div>
|
||||
|
||||
{/* 테이블 */}
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full border-collapse border border-gray-400 text-xs">
|
||||
<thead>
|
||||
{/* 1행: 상위 헤더 */}
|
||||
<tr className="bg-gray-50">
|
||||
<th rowSpan={2} className="border border-gray-400 px-2 py-2 text-center w-10">No.</th>
|
||||
<th rowSpan={2} className="border border-gray-400 px-2 py-2 text-center min-w-[80px]">명칭</th>
|
||||
<th rowSpan={2} className="border border-gray-400 px-2 py-2 text-center min-w-[60px]">제품</th>
|
||||
<th colSpan={2} className="border border-gray-400 px-2 py-1 text-center bg-red-50">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
규격 mm
|
||||
</span>
|
||||
</th>
|
||||
<th rowSpan={2} className="border border-gray-400 px-2 py-2 text-center w-12">수량</th>
|
||||
<th rowSpan={2} className="border border-gray-400 px-2 py-2 text-center w-12">단위</th>
|
||||
<th colSpan={2} className="border border-gray-400 px-2 py-1 text-center bg-red-50">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
계약금액
|
||||
</span>
|
||||
</th>
|
||||
<th colSpan={2} className="border border-gray-400 px-2 py-1 text-center bg-red-50">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
전회기성
|
||||
</span>
|
||||
</th>
|
||||
<th colSpan={2} className="border border-gray-400 px-2 py-1 text-center bg-red-50">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
금회기성
|
||||
</span>
|
||||
</th>
|
||||
<th colSpan={2} className="border border-gray-400 px-2 py-1 text-center bg-red-50">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
누계기성
|
||||
</span>
|
||||
</th>
|
||||
<th rowSpan={2} className="border border-gray-400 px-2 py-2 text-center w-16">비고</th>
|
||||
</tr>
|
||||
{/* 2행: 하위 헤더 */}
|
||||
<tr className="bg-gray-50">
|
||||
<th className="border border-gray-400 px-2 py-1 text-center w-14">가로</th>
|
||||
<th className="border border-gray-400 px-2 py-1 text-center w-14">세로</th>
|
||||
<th className="border border-gray-400 px-2 py-1 text-center w-16">단가</th>
|
||||
<th className="border border-gray-400 px-2 py-1 text-center w-20">금액</th>
|
||||
<th className="border border-gray-400 px-2 py-1 text-center w-12">수량</th>
|
||||
<th className="border border-gray-400 px-2 py-1 text-center w-20">금액</th>
|
||||
<th className="border border-gray-400 px-2 py-1 text-center w-12">수량</th>
|
||||
<th className="border border-gray-400 px-2 py-1 text-center w-20">금액</th>
|
||||
<th className="border border-gray-400 px-2 py-1 text-center w-12">수량</th>
|
||||
<th className="border border-gray-400 px-2 py-1 text-center w-20">금액</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((item, index) => (
|
||||
<tr key={item.id}>
|
||||
<td className="border border-gray-400 px-2 py-2 text-center">{index + 1}</td>
|
||||
<td className="border border-gray-400 px-2 py-2">{item.name}</td>
|
||||
<td className="border border-gray-400 px-2 py-2">{item.product}</td>
|
||||
<td className="border border-gray-400 px-2 py-2 text-right">{formatNumber(item.width)}</td>
|
||||
<td className="border border-gray-400 px-2 py-2 text-right">{formatNumber(item.height)}</td>
|
||||
<td className="border border-gray-400 px-2 py-2 text-center">{item.quantity}</td>
|
||||
<td className="border border-gray-400 px-2 py-2 text-center">{item.unit}</td>
|
||||
<td className="border border-gray-400 px-2 py-2 text-right">{formatNumber(item.contractUnitPrice)}</td>
|
||||
<td className="border border-gray-400 px-2 py-2 text-right">{formatNumber(item.contractAmount)}</td>
|
||||
<td className="border border-gray-400 px-2 py-2 text-center">{item.prevQuantity || '-'}</td>
|
||||
<td className="border border-gray-400 px-2 py-2 text-right">{item.prevAmount ? formatNumber(item.prevAmount) : '-'}</td>
|
||||
<td className="border border-gray-400 px-2 py-2 text-center">{item.currentQuantity}</td>
|
||||
<td className="border border-gray-400 px-2 py-2 text-right">{formatNumber(item.currentAmount)}</td>
|
||||
<td className="border border-gray-400 px-2 py-2 text-center">{item.cumulativeQuantity}</td>
|
||||
<td className="border border-gray-400 px-2 py-2 text-right">{formatNumber(item.cumulativeAmount)}</td>
|
||||
<td className="border border-gray-400 px-2 py-2">{item.remark || ''}</td>
|
||||
</tr>
|
||||
))}
|
||||
{/* 합계 행 */}
|
||||
<tr className="bg-gray-50 font-bold">
|
||||
<td colSpan={8} className="border border-gray-400 px-2 py-2 text-center">합계</td>
|
||||
<td className="border border-gray-400 px-2 py-2 text-right">{formatNumber(totalContractAmount)}</td>
|
||||
<td colSpan={2} className="border border-gray-400 px-2 py-2"></td>
|
||||
<td className="border border-gray-400 px-2 py-2"></td>
|
||||
<td className="border border-gray-400 px-2 py-2 text-right">{formatNumber(totalCurrentAmount)}</td>
|
||||
<td className="border border-gray-400 px-2 py-2"></td>
|
||||
<td className="border border-gray-400 px-2 py-2 text-right">{formatNumber(totalCumulativeAmount)}</td>
|
||||
<td className="border border-gray-400 px-2 py-2"></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,382 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
VisuallyHidden,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Edit, Trash2, Printer, X } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { printArea } from '@/lib/print-utils';
|
||||
import type { ProgressBillingDetailFormData } from '../types';
|
||||
|
||||
// 숫자 포맷팅 (천단위 콤마)
|
||||
function formatNumber(num: number | undefined): string {
|
||||
if (num === undefined || num === null) return '-';
|
||||
return num.toLocaleString('ko-KR');
|
||||
}
|
||||
|
||||
interface IndirectConstructionModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
data: ProgressBillingDetailFormData;
|
||||
}
|
||||
|
||||
// 간접 공사 내역 아이템 타입
|
||||
interface IndirectConstructionItem {
|
||||
id: string;
|
||||
name: string;
|
||||
spec: string;
|
||||
unit: string;
|
||||
contractQuantity: number;
|
||||
contractAmount: number;
|
||||
prevQuantity: number;
|
||||
prevAmount: number;
|
||||
currentQuantity: number;
|
||||
currentAmount: number;
|
||||
cumulativeQuantity: number;
|
||||
cumulativeAmount: number;
|
||||
remark?: string;
|
||||
}
|
||||
|
||||
// 목업 데이터 생성
|
||||
function generateMockItems(): IndirectConstructionItem[] {
|
||||
return [
|
||||
{
|
||||
id: '1',
|
||||
name: '국민연금',
|
||||
spec: '직접노무비 × 4.50%',
|
||||
unit: '식',
|
||||
contractQuantity: 1,
|
||||
contractAmount: 2500000,
|
||||
prevQuantity: 0,
|
||||
prevAmount: 0,
|
||||
currentQuantity: 0,
|
||||
currentAmount: 2500000,
|
||||
cumulativeQuantity: 0,
|
||||
cumulativeAmount: 2500000,
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: '건강보험',
|
||||
spec: '직접노무비 × 3.545%',
|
||||
unit: '식',
|
||||
contractQuantity: 1,
|
||||
contractAmount: 2500000,
|
||||
prevQuantity: 0,
|
||||
prevAmount: 0,
|
||||
currentQuantity: 0,
|
||||
currentAmount: 2500000,
|
||||
cumulativeQuantity: 0,
|
||||
cumulativeAmount: 2500000,
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
name: '노인장기요양보험료',
|
||||
spec: '건강보험료 × 12.81%',
|
||||
unit: '식',
|
||||
contractQuantity: 1,
|
||||
contractAmount: 2500000,
|
||||
prevQuantity: 0,
|
||||
prevAmount: 0,
|
||||
currentQuantity: 0,
|
||||
currentAmount: 2500000,
|
||||
cumulativeQuantity: 0,
|
||||
cumulativeAmount: 2500000,
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
name: '고용보험',
|
||||
spec: '직접공사비 × 30% × 1.57%',
|
||||
unit: '식',
|
||||
contractQuantity: 1,
|
||||
contractAmount: 2500000,
|
||||
prevQuantity: 0,
|
||||
prevAmount: 0,
|
||||
currentQuantity: 0,
|
||||
currentAmount: 2500000,
|
||||
cumulativeQuantity: 0,
|
||||
cumulativeAmount: 2500000,
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
name: '일반관리비',
|
||||
spec: '1) 직접공사비 × 업체요율\n2) 공과물비+작업비 시공비 포함',
|
||||
unit: '식',
|
||||
contractQuantity: 1,
|
||||
contractAmount: 2500000,
|
||||
prevQuantity: 0,
|
||||
prevAmount: 0,
|
||||
currentQuantity: 0,
|
||||
currentAmount: 2500000,
|
||||
cumulativeQuantity: 0,
|
||||
cumulativeAmount: 2500000,
|
||||
},
|
||||
{
|
||||
id: '6',
|
||||
name: '안전관리비',
|
||||
spec: '직접공사비 × 0.3%(일반건산)',
|
||||
unit: '식',
|
||||
contractQuantity: 1,
|
||||
contractAmount: 2500000,
|
||||
prevQuantity: 0,
|
||||
prevAmount: 0,
|
||||
currentQuantity: 0,
|
||||
currentAmount: 2500000,
|
||||
cumulativeQuantity: 0,
|
||||
cumulativeAmount: 2500000,
|
||||
},
|
||||
{
|
||||
id: '7',
|
||||
name: '안전검사자',
|
||||
spec: '실투입 × 양정실시',
|
||||
unit: 'M/D',
|
||||
contractQuantity: 1,
|
||||
contractAmount: 2500000,
|
||||
prevQuantity: 0,
|
||||
prevAmount: 0,
|
||||
currentQuantity: 0,
|
||||
currentAmount: 2500000,
|
||||
cumulativeQuantity: 0,
|
||||
cumulativeAmount: 2500000,
|
||||
},
|
||||
{
|
||||
id: '8',
|
||||
name: '신호수 및 위기감시자',
|
||||
spec: '실투입 × 양정실시',
|
||||
unit: 'M/D',
|
||||
contractQuantity: 1,
|
||||
contractAmount: 2500000,
|
||||
prevQuantity: 0,
|
||||
prevAmount: 0,
|
||||
currentQuantity: 0,
|
||||
currentAmount: 2500000,
|
||||
cumulativeQuantity: 0,
|
||||
cumulativeAmount: 2500000,
|
||||
},
|
||||
{
|
||||
id: '9',
|
||||
name: '퇴직공제부금',
|
||||
spec: '직접노무비 × 2.3%',
|
||||
unit: '식',
|
||||
contractQuantity: 1,
|
||||
contractAmount: 2500000,
|
||||
prevQuantity: 0,
|
||||
prevAmount: 0,
|
||||
currentQuantity: 0,
|
||||
currentAmount: 2500000,
|
||||
cumulativeQuantity: 0,
|
||||
cumulativeAmount: 2500000,
|
||||
},
|
||||
{
|
||||
id: '10',
|
||||
name: '폐기물처리비',
|
||||
spec: '직접공사비 × 요제요율이상',
|
||||
unit: '식',
|
||||
contractQuantity: 1,
|
||||
contractAmount: 2500000,
|
||||
prevQuantity: 0,
|
||||
prevAmount: 0,
|
||||
currentQuantity: 0,
|
||||
currentAmount: 2500000,
|
||||
cumulativeQuantity: 0,
|
||||
cumulativeAmount: 2500000,
|
||||
},
|
||||
{
|
||||
id: '11',
|
||||
name: '건설기계대여자금보증료',
|
||||
spec: '(직접비+간접공사비) × 0.07%',
|
||||
unit: '식',
|
||||
contractQuantity: 1,
|
||||
contractAmount: 2500000,
|
||||
prevQuantity: 0,
|
||||
prevAmount: 0,
|
||||
currentQuantity: 0,
|
||||
currentAmount: 2500000,
|
||||
cumulativeQuantity: 0,
|
||||
cumulativeAmount: 2500000,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function IndirectConstructionModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
data,
|
||||
}: IndirectConstructionModalProps) {
|
||||
// 핸들러
|
||||
const handleEdit = () => {
|
||||
toast.info('수정 기능은 준비 중입니다.');
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
toast.info('삭제 기능은 준비 중입니다.');
|
||||
};
|
||||
|
||||
const handlePrint = () => {
|
||||
printArea({ title: '간접 공사 내역서 인쇄' });
|
||||
};
|
||||
|
||||
// 목업 데이터
|
||||
const items = generateMockItems();
|
||||
|
||||
// 합계 계산
|
||||
const totalContractAmount = items.reduce((sum, item) => sum + item.contractAmount, 0);
|
||||
const totalCurrentAmount = items.reduce((sum, item) => sum + item.currentAmount, 0);
|
||||
const totalCumulativeAmount = items.reduce((sum, item) => sum + item.cumulativeAmount, 0);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-[95vw] md:max-w-[1000px] lg:max-w-[1200px] h-[95vh] p-0 flex flex-col gap-0 overflow-hidden [&>button]:hidden">
|
||||
<VisuallyHidden>
|
||||
<DialogTitle>간접 공사 내역서</DialogTitle>
|
||||
</VisuallyHidden>
|
||||
|
||||
{/* 헤더 영역 */}
|
||||
<div className="print-hidden flex-shrink-0 flex items-center justify-between px-6 py-4 border-b bg-white">
|
||||
<h2 className="text-lg font-semibold">간접 공사 내역서</h2>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => onOpenChange(false)}
|
||||
className="h-8 w-8"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 버튼 영역 */}
|
||||
<div className="print-hidden flex-shrink-0 flex items-center gap-2 px-6 py-3 bg-muted/30 border-b">
|
||||
<Button variant="outline" size="sm" onClick={handleEdit}>
|
||||
<Edit className="h-4 w-4 mr-1" />
|
||||
수정
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={handleDelete}>
|
||||
<Trash2 className="h-4 w-4 mr-1" />
|
||||
삭제
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={handlePrint}>
|
||||
<Printer className="h-4 w-4 mr-1" />
|
||||
인쇄
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 문서 영역 */}
|
||||
<div className="print-area flex-1 overflow-y-auto bg-gray-100 p-6">
|
||||
<div className="max-w-[297mm] mx-auto bg-white shadow-lg rounded-lg p-8">
|
||||
{/* 상단: 제목 + 결재란 */}
|
||||
<div className="flex items-start justify-between mb-6">
|
||||
{/* 좌측: 제목 및 문서 정보 */}
|
||||
<div className="flex-1">
|
||||
<h1 className="text-2xl font-bold mb-2">간접 공사 내역서</h1>
|
||||
<div className="text-sm text-gray-600">
|
||||
문서번호: {data.billingNumber || 'ABC123'} | 작성일자: 2025년 11월 11일
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 우측: 결재란 */}
|
||||
<table className="border-collapse border border-gray-400 text-sm">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th rowSpan={3} className="border border-gray-400 px-3 py-1 bg-gray-50 text-center align-middle w-12">
|
||||
결<br />재
|
||||
</th>
|
||||
<th className="border border-gray-400 px-6 py-1 bg-gray-50 text-center whitespace-nowrap">작성</th>
|
||||
<th className="border border-gray-400 px-6 py-1 bg-gray-50 text-center whitespace-nowrap">승인</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="border border-gray-400 px-6 py-2 text-center h-10 whitespace-nowrap">홍길동</td>
|
||||
<td className="border border-gray-400 px-6 py-2 text-center h-10 whitespace-nowrap">이름</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="border border-gray-400 px-6 py-1 text-center text-xs text-gray-500 whitespace-nowrap">부서명</td>
|
||||
<td className="border border-gray-400 px-6 py-1 text-center text-xs text-gray-500 whitespace-nowrap">부서명</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* 기성내역 제목 */}
|
||||
<div className="text-center font-bold text-lg mb-4">
|
||||
기성내역 제{data.billingRound || 1}회 ({data.billingYearMonth || '2025년 11월'})
|
||||
</div>
|
||||
|
||||
{/* 현장 정보 */}
|
||||
<div className="mb-4">
|
||||
<span className="font-bold">■ 현장: {data.siteName || '현장명'}</span>
|
||||
</div>
|
||||
|
||||
{/* 테이블 */}
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full border-collapse border border-gray-400 text-xs">
|
||||
<thead>
|
||||
{/* 1행: 상위 헤더 */}
|
||||
<tr className="bg-gray-50">
|
||||
<th rowSpan={2} className="border border-gray-400 px-2 py-2 text-center w-10">No.</th>
|
||||
<th rowSpan={2} className="border border-gray-400 px-2 py-2 text-center min-w-[100px]">품명</th>
|
||||
<th rowSpan={2} className="border border-gray-400 px-2 py-2 text-center min-w-[180px]">규격</th>
|
||||
<th rowSpan={2} className="border border-gray-400 px-2 py-2 text-center w-14">단위</th>
|
||||
<th colSpan={2} className="border border-gray-400 px-2 py-1 text-center bg-red-50">
|
||||
계약금액
|
||||
</th>
|
||||
<th colSpan={2} className="border border-gray-400 px-2 py-1 text-center bg-red-50">
|
||||
전회기성
|
||||
</th>
|
||||
<th colSpan={2} className="border border-gray-400 px-2 py-1 text-center bg-red-50">
|
||||
금회기성
|
||||
</th>
|
||||
<th colSpan={2} className="border border-gray-400 px-2 py-1 text-center bg-red-50">
|
||||
누계기성
|
||||
</th>
|
||||
<th rowSpan={2} className="border border-gray-400 px-2 py-2 text-center w-16">비고</th>
|
||||
</tr>
|
||||
{/* 2행: 하위 헤더 */}
|
||||
<tr className="bg-gray-50">
|
||||
<th className="border border-gray-400 px-2 py-1 text-center w-12">수량</th>
|
||||
<th className="border border-gray-400 px-2 py-1 text-center w-20">금액</th>
|
||||
<th className="border border-gray-400 px-2 py-1 text-center w-12">수량</th>
|
||||
<th className="border border-gray-400 px-2 py-1 text-center w-20">금액</th>
|
||||
<th className="border border-gray-400 px-2 py-1 text-center w-12">수량</th>
|
||||
<th className="border border-gray-400 px-2 py-1 text-center w-20">금액</th>
|
||||
<th className="border border-gray-400 px-2 py-1 text-center w-12">수량</th>
|
||||
<th className="border border-gray-400 px-2 py-1 text-center w-20">금액</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((item, index) => (
|
||||
<tr key={item.id}>
|
||||
<td className="border border-gray-400 px-2 py-2 text-center">{index + 1}</td>
|
||||
<td className="border border-gray-400 px-2 py-2">{item.name}</td>
|
||||
<td className="border border-gray-400 px-2 py-2 whitespace-pre-line text-xs">{item.spec}</td>
|
||||
<td className="border border-gray-400 px-2 py-2 text-center">{item.unit}</td>
|
||||
<td className="border border-gray-400 px-2 py-2 text-center">{item.contractQuantity}</td>
|
||||
<td className="border border-gray-400 px-2 py-2 text-right">{formatNumber(item.contractAmount)}</td>
|
||||
<td className="border border-gray-400 px-2 py-2 text-center">{item.prevQuantity || '-'}</td>
|
||||
<td className="border border-gray-400 px-2 py-2 text-right">{item.prevAmount ? formatNumber(item.prevAmount) : '-'}</td>
|
||||
<td className="border border-gray-400 px-2 py-2 text-center">{item.currentQuantity || '-'}</td>
|
||||
<td className="border border-gray-400 px-2 py-2 text-right">{formatNumber(item.currentAmount)}</td>
|
||||
<td className="border border-gray-400 px-2 py-2 text-center">{item.cumulativeQuantity || '-'}</td>
|
||||
<td className="border border-gray-400 px-2 py-2 text-right">{formatNumber(item.cumulativeAmount)}</td>
|
||||
<td className="border border-gray-400 px-2 py-2">{item.remark || ''}</td>
|
||||
</tr>
|
||||
))}
|
||||
{/* 합계 행 */}
|
||||
<tr className="bg-gray-50 font-bold">
|
||||
<td colSpan={5} className="border border-gray-400 px-2 py-2 text-center">합계</td>
|
||||
<td className="border border-gray-400 px-2 py-2 text-right">{formatNumber(totalContractAmount)}</td>
|
||||
<td colSpan={2} className="border border-gray-400 px-2 py-2"></td>
|
||||
<td colSpan={2} className="border border-gray-400 px-2 py-2 text-right">{formatNumber(totalCurrentAmount)}</td>
|
||||
<td colSpan={2} className="border border-gray-400 px-2 py-2 text-right">{formatNumber(totalCumulativeAmount)}</td>
|
||||
<td className="border border-gray-400 px-2 py-2"></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
VisuallyHidden,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Edit, Trash2, Printer, X } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { printArea } from '@/lib/print-utils';
|
||||
import type { ProgressBillingDetailFormData } from '../types';
|
||||
|
||||
interface PhotoDocumentModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
data: ProgressBillingDetailFormData;
|
||||
}
|
||||
|
||||
// 사진대지 아이템 타입
|
||||
interface PhotoDocumentItem {
|
||||
id: string;
|
||||
imageUrl: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
// 목업 데이터 생성
|
||||
function generateMockPhotos(photoItems: ProgressBillingDetailFormData['photoItems']): PhotoDocumentItem[] {
|
||||
// 기존 photoItems에서 선택된 사진들을 가져오거나 목업 생성
|
||||
const photos: PhotoDocumentItem[] = [];
|
||||
|
||||
photoItems.forEach((item) => {
|
||||
if (item.photos && item.photos.length > 0) {
|
||||
const selectedIndex = item.selectedPhotoIndex ?? 0;
|
||||
photos.push({
|
||||
id: item.id,
|
||||
imageUrl: item.photos[selectedIndex] || item.photos[0],
|
||||
name: item.name,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 최소 6개 항목 채우기 (2열 × 3행)
|
||||
while (photos.length < 6) {
|
||||
photos.push({
|
||||
id: `mock-${photos.length}`,
|
||||
imageUrl: '',
|
||||
name: '명칭',
|
||||
});
|
||||
}
|
||||
|
||||
return photos;
|
||||
}
|
||||
|
||||
export function PhotoDocumentModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
data,
|
||||
}: PhotoDocumentModalProps) {
|
||||
// 핸들러
|
||||
const handleEdit = () => {
|
||||
toast.info('수정 기능은 준비 중입니다.');
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
toast.info('삭제 기능은 준비 중입니다.');
|
||||
};
|
||||
|
||||
const handlePrint = () => {
|
||||
printArea({ title: '사진대지 인쇄' });
|
||||
};
|
||||
|
||||
// 목업 데이터
|
||||
const photos = generateMockPhotos(data.photoItems);
|
||||
|
||||
// 2열로 그룹화
|
||||
const photoRows: PhotoDocumentItem[][] = [];
|
||||
for (let i = 0; i < photos.length; i += 2) {
|
||||
photoRows.push(photos.slice(i, i + 2));
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-[95vw] md:max-w-[900px] lg:max-w-[1000px] h-[95vh] p-0 flex flex-col gap-0 overflow-hidden [&>button]:hidden">
|
||||
<VisuallyHidden>
|
||||
<DialogTitle>사진대지</DialogTitle>
|
||||
</VisuallyHidden>
|
||||
|
||||
{/* 헤더 영역 */}
|
||||
<div className="print-hidden flex-shrink-0 flex items-center justify-between px-6 py-4 border-b bg-white">
|
||||
<h2 className="text-lg font-semibold">사진대지</h2>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => onOpenChange(false)}
|
||||
className="h-8 w-8"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 버튼 영역 */}
|
||||
<div className="print-hidden flex-shrink-0 flex items-center gap-2 px-6 py-3 bg-muted/30 border-b">
|
||||
<Button variant="outline" size="sm" onClick={handleEdit}>
|
||||
<Edit className="h-4 w-4 mr-1" />
|
||||
수정
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={handleDelete}>
|
||||
<Trash2 className="h-4 w-4 mr-1" />
|
||||
삭제
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={handlePrint}>
|
||||
<Printer className="h-4 w-4 mr-1" />
|
||||
인쇄
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 문서 영역 */}
|
||||
<div className="print-area flex-1 overflow-y-auto bg-gray-100 p-6">
|
||||
<div className="max-w-[210mm] mx-auto bg-white shadow-lg rounded-lg p-8">
|
||||
{/* 상단: 제목 + 결재란 */}
|
||||
<div className="flex items-start justify-between mb-6">
|
||||
{/* 좌측: 제목 및 문서 정보 */}
|
||||
<div className="flex-1">
|
||||
<h1 className="text-2xl font-bold mb-2">사진대지</h1>
|
||||
<div className="text-sm text-gray-600">
|
||||
문서번호: {data.billingNumber || 'ABC123'} | 작성일자: 2025년 11월 11일
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 우측: 결재란 */}
|
||||
<table className="border-collapse border border-gray-400 text-sm">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th rowSpan={3} className="border border-gray-400 px-3 py-1 bg-gray-50 text-center align-middle w-12">
|
||||
결<br />재
|
||||
</th>
|
||||
<th className="border border-gray-400 px-6 py-1 bg-gray-50 text-center whitespace-nowrap">작성</th>
|
||||
<th className="border border-gray-400 px-6 py-1 bg-gray-50 text-center whitespace-nowrap">승인</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="border border-gray-400 px-6 py-2 text-center h-10 whitespace-nowrap">홍길동</td>
|
||||
<td className="border border-gray-400 px-6 py-2 text-center h-10 whitespace-nowrap">이름</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="border border-gray-400 px-6 py-1 text-center text-xs text-gray-500 whitespace-nowrap">부서명</td>
|
||||
<td className="border border-gray-400 px-6 py-1 text-center text-xs text-gray-500 whitespace-nowrap">부서명</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* 기성신청 사진대지 제목 */}
|
||||
<div className="text-center font-bold text-lg mb-4">
|
||||
기성신청 사진대지 제{data.billingRound || 1}회 ({data.billingYearMonth || '2025년 11월'})
|
||||
</div>
|
||||
|
||||
{/* 현장 정보 */}
|
||||
<div className="mb-6">
|
||||
<span className="font-bold">■ 현장: {data.siteName || '현장명'}</span>
|
||||
</div>
|
||||
|
||||
{/* 사진 그리드 */}
|
||||
<div className="border border-gray-400">
|
||||
{photoRows.map((row, rowIndex) => (
|
||||
<div key={rowIndex} className="grid grid-cols-2">
|
||||
{row.map((photo, colIndex) => (
|
||||
<div
|
||||
key={photo.id}
|
||||
className={`border border-gray-400 ${colIndex === 0 ? 'border-l-0' : ''} ${rowIndex === 0 ? 'border-t-0' : ''}`}
|
||||
>
|
||||
{/* 이미지 영역 */}
|
||||
<div className="aspect-[4/3] bg-gray-100 flex items-center justify-center overflow-hidden">
|
||||
{photo.imageUrl ? (
|
||||
<img
|
||||
src={photo.imageUrl}
|
||||
alt={photo.name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-gray-400 text-lg">IMG</span>
|
||||
)}
|
||||
</div>
|
||||
{/* 명칭 라벨 */}
|
||||
<div className="border-t border-gray-400 px-4 py-3 text-center bg-white">
|
||||
<span className="text-sm font-medium">{photo.name}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{/* 홀수 개일 때 빈 셀 채우기 */}
|
||||
{row.length === 1 && (
|
||||
<div className="border border-gray-400 border-t-0">
|
||||
<div className="aspect-[4/3] bg-gray-100 flex items-center justify-center">
|
||||
<span className="text-gray-400 text-lg">IMG</span>
|
||||
</div>
|
||||
<div className="border-t border-gray-400 px-4 py-3 text-center bg-white">
|
||||
<span className="text-sm font-medium">명칭</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user