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:
2026-01-13 19:58:09 +09:00
132 changed files with 19588 additions and 1251 deletions

View File

@@ -1,6 +1,7 @@
# 권한 관리 시스템 현황 분석
> 작성일: 2026-01-07
> 최종 수정일: 2026-01-12
> 목적: SAM 프로젝트 권한 시스템 현황 파악 및 향후 구현 계획 정리
---
@@ -10,13 +11,13 @@
| 구분 | 상태 | 설명 |
|------|------|------|
| 권한 설정 UI | ✅ 완성 | `/settings/permissions/[id]`에서 역할별 권한 설정 가능 |
| 백엔드 권한 API | ✅ 존재 | 권한 매트릭스 조회/설정 API 구현됨 |
| 백엔드 권한 API | ✅ 완성 | 권한 매트릭스 조회/설정 API 구현됨 |
| 백엔드 API 권한 체크 | ⚠️ 구조만 있음 | 미들웨어 존재하나 라우트에 미적용 |
| 프론트 권한 체크 | ❌ 미구현 | 권한 매트릭스 조회 및 UI 제어 로직 없음 |
---
## 2. 권한 타입 (7가지)
## 2. 권한 타입 (5가지)
| 권한 | 영문 | 적용 대상 |
|------|------|----------|
@@ -25,8 +26,8 @@
| 수정 | `update` | 수정 버튼 |
| 삭제 | `delete` | 삭제 버튼 |
| 승인 | `approve` | 승인/반려 버튼 |
| 내보내기 | `export` | Excel 다운로드 등 |
| 관리 | `manage` | 관리자 전용 기능 |
> ⚠️ **참고**: `export`, `manage` 권한은 백엔드에 미구현 상태
---
@@ -51,32 +52,91 @@
### 3.2 권한 매트릭스 조회 API
**사용자별 권한 조회**:
**사용자별 권한 조회** (프론트엔드에서 사용):
```
GET /api/v1/permissions/users/{userId}/menu-matrix
```
**응답 구조**:
**실제 응답 구조**:
```json
{
"permission_types": ["view", "create", "update", "delete", "approve", "export", "manage"],
"permissions": {
"1": { "view": true, "create": true, "update": false, ... },
"2": { "view": true, "create": false, ... }
"success": true,
"message": "유저 메뉴 권한 매트릭스 조회 성공",
"data": {
"actions": ["view", "create", "update", "delete", "approve"],
"tree": [
{
"menu_id": 1,
"parent_id": null,
"name": "대시보드",
"url": "/dashboard",
"type": "system",
"children": [
{
"menu_id": 2,
"parent_id": 1,
"name": "CEO 대시보드",
"url": "/dashboard/ceo",
"children": [],
"actions": { ... }
}
],
"actions": {
"view": {
"permission_id": 123,
"permission_code": "menu:1.view",
"guard_name": "api",
"state": "allow",
"is_allowed": 1
},
"create": {
"permission_id": 124,
"permission_code": "menu:1.create",
"guard_name": "api",
"state": "deny",
"is_allowed": 0
},
"update": null,
"delete": null,
"approve": null
}
}
]
}
}
```
### 3.3 기타 권한 API
**권한 상태 값**:
| state | is_allowed | 의미 |
|-------|------------|------|
| `allow` | 1 | 권한 허용됨 |
| `deny` | 0 | 권한 명시적 거부 |
| `none` | 0 | 권한 미설정 (기본 거부) |
| 엔드포인트 | 설명 |
|-----------|------|
| `GET /api/v1/permissions/departments/{dept_id}/menu-matrix` | 부서별 권한 매트릭스 |
| `GET /api/v1/permissions/roles/{role_id}/menu-matrix` | 역할별 권한 매트릭스 |
| `GET /api/v1/roles/{id}/permissions/matrix` | 역할 권한 매트릭스 (설정 UI용) |
| `POST /api/v1/roles/{id}/permissions/toggle` | 개별 권한 토글 |
| `POST /api/v1/roles/{id}/permissions/allow-all` | 전체 허용 |
| `POST /api/v1/roles/{id}/permissions/deny-all` | 전체 거부 |
**actions가 null인 경우**: 해당 메뉴에 해당 권한이 정의되지 않음
### 3.3 권한 매트릭스 API 목록
| 엔드포인트 | 메서드 | 설명 |
|-----------|--------|------|
| `/api/v1/permissions/users/{user_id}/menu-matrix` | GET | 사용자별 권한 매트릭스 |
| `/api/v1/permissions/roles/{role_id}/menu-matrix` | GET | 역할별 권한 매트릭스 |
| `/api/v1/permissions/departments/{dept_id}/menu-matrix` | GET | 부서별 권한 매트릭스 |
### 3.4 역할 권한 관리 API
| 엔드포인트 | 메서드 | 설명 |
|-----------|--------|------|
| `/api/v1/role-permissions/menus` | GET | 권한 설정용 메뉴 트리 |
| `/api/v1/roles/{id}/permissions` | GET | 역할 권한 목록 |
| `/api/v1/roles/{id}/permissions` | POST | 역할 권한 부여 |
| `/api/v1/roles/{id}/permissions` | DELETE | 역할 권한 회수 |
| `/api/v1/roles/{id}/permissions/sync` | PUT | 역할 권한 동기화 |
| `/api/v1/roles/{id}/permissions/matrix` | GET | 역할 권한 매트릭스 (설정 UI용) |
| `/api/v1/roles/{id}/permissions/toggle` | POST | 개별 권한 토글 |
| `/api/v1/roles/{id}/permissions/allow-all` | POST | 전체 허용 |
| `/api/v1/roles/{id}/permissions/deny-all` | POST | 전체 거부 |
| `/api/v1/roles/{id}/permissions/reset` | POST | 기본값 초기화 (view만 허용) |
---
@@ -124,13 +184,15 @@ HTTP 메서드에 따라 액션 자동 매핑:
- 로그인 시 `menus`, `roles` 데이터 저장 (localStorage)
- 사이드바 메뉴 표시 (백엔드에서 필터링된 메뉴)
- 메뉴 폴링 (30초 주기)
- 역할별 권한 설정 UI (`/settings/permissions/[id]`)
### 5.2 미구현 사항
- 권한 매트릭스 API 호출
- 권한 데이터 저장
- 권한 데이터 저장 (permissionStore)
- `usePermission`
- 페이지/버튼별 권한 체크
- 환경 변수 플래그
---
@@ -143,7 +205,7 @@ HTTP 메서드에 따라 액션 자동 매핑:
/api/v1/permissions/users/{userId}/menu-matrix 호출
권한 매트릭스 저장 (Zustand/localStorage)
권한 매트릭스 저장 (Zustand permissionStore)
usePermission 훅으로 권한 체크
@@ -152,12 +214,13 @@ usePermission 훅으로 권한 체크
**usePermission 훅 예시**:
```typescript
// 사용법
const { canView, canCreate, canUpdate, canDelete } = usePermission('판매관리');
// 사용법 (메뉴명 또는 URL로 조회)
const { canView, canCreate, canUpdate, canDelete, canApprove } = usePermission('/sales/orders');
// 적용
{canCreate && <Button>등록</Button>}
{canDelete && <Button>삭제</Button>}
{canApprove && <Button>승인</Button>}
```
**환경 변수 플래그**:
@@ -209,6 +272,7 @@ Route::post('/orders', [OrderController::class, 'store'])
| `src/components/settings/PermissionManagement/` | 권한 관리 컴포넌트 |
| `src/layouts/AuthenticatedLayout.tsx` | 메뉴 표시 레이아웃 |
| `src/middleware.ts` | 인증 체크 (권한 체크 없음) |
| `src/store/menuStore.ts` | 메뉴 상태 관리 |
### 백엔드 (sam-api)
@@ -218,7 +282,9 @@ Route::post('/orders', [OrderController::class, 'store'])
| `app/Http/Controllers/Api/V1/RolePermissionController.php` | 역할 권한 API |
| `app/Http/Middleware/CheckPermission.php` | 권한 체크 미들웨어 |
| `app/Http/Middleware/PermMapper.php` | HTTP → 액션 매핑 |
| `app/Services/PermissionService.php` | 권한 매트릭스 서비스 |
| `app/Services/Authz/AccessService.php` | 권한 판정 서비스 |
| `app/Services/Authz/RolePermissionService.php` | 역할 권한 서비스 |
---

View File

@@ -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 스피너 규칙 추가*

View File

@@ -0,0 +1,153 @@
# 프론트엔드 권한 시스템 구현 체크리스트
> 작성일: 2026-01-12
> 참고 문서: [ANALYSIS-2026-01-07] permission-system-status.md
---
## 구현 목표
로그인한 사용자의 권한에 따라 UI 요소(버튼, 메뉴 등)를 동적으로 표시/숨김 처리
---
## Phase 1: 기반 구조 구축
### 1.1 타입 정의
- [ ] `src/types/permission.ts` 생성
- [ ] `PermissionAction` 타입 (view, create, update, delete, approve)
- [ ] `PermissionState` 타입 (allow, deny, none)
- [ ] `MenuPermission` 인터페이스 (API 응답 구조)
- [ ] `PermissionMatrix` 인터페이스 (트리 → 플랫 변환용)
### 1.2 환경 변수 설정
- [ ] `.env.local``NEXT_PUBLIC_ENABLE_AUTHORIZATION=false` 추가
- [ ] `.env.example`에 동일 항목 추가 (문서화)
---
## Phase 2: 상태 관리
### 2.1 Permission Store 생성
- [ ] `src/store/permissionStore.ts` 생성
- [ ] 상태 정의
- [ ] `permissions`: URL 기반 권한 맵 (`Record<string, PermissionActions>`)
- [ ] `isLoaded`: 권한 로딩 완료 여부
- [ ] `isEnabled`: 환경 변수 기반 활성화 여부
- [ ] 액션 정의
- [ ] `setPermissions(tree)`: API 응답 트리를 플랫 맵으로 변환 저장
- [ ] `clearPermissions()`: 로그아웃 시 초기화
- [ ] `hasPermission(url, action)`: 권한 체크 함수
- [ ] persist 미들웨어 적용 (localStorage)
### 2.2 유틸리티 함수
- [ ] `src/lib/permission-utils.ts` 생성
- [ ] `flattenPermissionTree(tree)`: 트리 구조를 URL 기반 플랫 맵으로 변환
- [ ] `normalizeUrl(url)`: URL 정규화 (locale 제거 등)
---
## Phase 3: API 연동
### 3.1 Server Action 생성
- [ ] `src/lib/api/permissions/actions.ts` 생성
- [ ] `getUserPermissions(userId)`: 권한 매트릭스 API 호출
### 3.2 로그인 플로우 연동
- [ ] 로그인 성공 후 권한 API 호출 로직 추가
- [ ] `AuthenticatedLayout.tsx` 또는 로그인 처리 부분에서 호출
- [ ] 권한 로딩 중 상태 처리 (로딩 UI 또는 스켈레톤)
---
## Phase 4: usePermission 훅 구현
### 4.1 훅 생성
- [ ] `src/hooks/usePermission.ts` 생성
- [ ] 입력: 메뉴 URL 또는 메뉴명
- [ ] 출력:
```typescript
{
canView: boolean;
canCreate: boolean;
canUpdate: boolean;
canDelete: boolean;
canApprove: boolean;
isLoading: boolean;
}
```
- [ ] 환경 변수 비활성화 시 모두 `true` 반환
### 4.2 편의 컴포넌트 (선택사항)
- [ ] `src/components/common/PermissionGuard.tsx` 생성
```typescript
<PermissionGuard menu="/sales/orders" action="create">
<Button>등록</Button>
</PermissionGuard>
```
---
## Phase 5: 적용 및 테스트
### 5.1 샘플 페이지 적용
- [ ] 테스트용 페이지 1개 선정 (예: 판매관리)
- [ ] 등록/수정/삭제 버튼에 권한 체크 적용
- [ ] 동작 확인
### 5.2 전체 적용 (점진적)
- [ ] 주요 페이지 목록 작성
- [ ] 각 페이지별 권한 적용 진행
---
## Phase 6: 예외 처리 및 UX
### 6.1 에러 처리
- [ ] 권한 API 실패 시 fallback 처리 (모두 허용 or 모두 거부)
- [ ] 네트워크 오류 시 재시도 로직
### 6.2 UX 개선
- [ ] 권한 없는 버튼: 숨김 vs 비활성화(disabled) 정책 결정
- [ ] 권한 없는 페이지 접근 시 처리 (리다이렉트 or 안내 메시지)
---
## 파일 생성 목록 요약
| 파일 경로 | 설명 |
|----------|------|
| `src/types/permission.ts` | 권한 관련 타입 정의 |
| `src/store/permissionStore.ts` | 권한 상태 관리 (Zustand) |
| `src/lib/permission-utils.ts` | 권한 유틸리티 함수 |
| `src/lib/api/permissions/actions.ts` | 권한 API Server Action |
| `src/hooks/usePermission.ts` | 권한 체크 훅 |
| `src/components/common/PermissionGuard.tsx` | 권한 가드 컴포넌트 (선택) |
---
## 의존성
- 추가 패키지 설치 불필요 (기존 Zustand 활용)
---
## 주의사항
1. **환경 변수 기본값**: 개발 중에는 `NEXT_PUBLIC_ENABLE_AUTHORIZATION=false`로 비활성화
2. **플랫 맵 변환**: API 응답이 트리 구조이므로 URL 기반 플랫 맵으로 변환 필요
3. **URL 정규화**: locale prefix (`/ko`, `/en`) 제거하여 비교
4. **로그아웃 시 초기화**: permissionStore 클리어 필수
---
## 예상 작업 순서
```
Phase 1 (타입/환경변수) → Phase 2 (스토어) → Phase 3 (API 연동)
→ Phase 4 (훅) → Phase 5 (적용) → Phase 6 (예외처리)
```
---
*체크리스트 완료 후 이 문서를 archive로 이동*

View File

@@ -0,0 +1,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

View File

@@ -0,0 +1,133 @@
# [IMPL-2026-01-12] 견적 V2 테스트 페이지 구현
## 개요
- **목적**: 견적 등록/상세/수정 페이지의 새로운 UI (자동 견적 산출 V2) 테스트
- **원칙**: 기존 견적관리 페이지는 절대 수정하지 않음 (API 연결됨)
- **범위**: 테스트 페이지 3개 + 새 컴포넌트 생성
---
## 스크린샷 기반 UI 구성
### 레이아웃 구조
```
┌─────────────────────────────────────────────────────────────┐
│ [발주 개소 목록 (3)] │ [1층 / FSS-01 상세정보] │
│ ┌──────────────────────┐ │ 제품명: KSS01 │
│ │ 층 │ 부호 │사이즈│제품│수량│ 오픈사이즈: 5000 × 3000 │
│ │ 1층│FSS-01│5000×3000│KSS01│1│ 제작사이즈/중량/면적/수량 │
│ │ 3층│FST-30│7500×3300│KSS02│1│ ───────────────────── │
│ │ 5층│FSS-50│6000×2800│KSS01│2│ 필수설정: 가이드레일/전원/제어기│
│ └──────────────────────┘ │ ───────────────────── │
│ [품목 추가 폼] │ [탭: 본체│철골품-가이드레일│...]│
│ 층|부호|가로|세로|제품명|수량 │ [품목 테이블] │
│ 가이드레일|전원|제어기 [+][↑] │ │
├─────────────────────────────────────────────────────────────┤
│ 💰 견적 금액 요약 │
│ ┌─────────────────┐ ┌──────────────────────────────┐ │
│ │ 개소별 합계 │ │ 상세별 합계 (선택 개소) │ │
│ │ 1층/FSS-01 1,645,200│ │ 본체(스크린/슬랫) 1,061,676 │ │
│ │ 3층/FST-30 2,589,198│ │ 철골품-가이드레일 116,556 │ │
│ │ 5층/FSS-50 3,442,428│ │ ... │ │
│ └─────────────────┘ └──────────────────────────────┘ │
├─────────────────────────────────────────────────────────────┤
│ 총 개소 수: 3 │ 예상 견적금액: 11,119,254 │ 견적상태: 작성중│
├─────────────────────────────────────────────────────────────┤
│ 예상 전체 견적금액 [견적서산출] [임시저장] [최종저장] │
│ 11,119,254원 │
└─────────────────────────────────────────────────────────────┘
```
### 기능 요약
| 영역 | 기능 |
|------|------|
| 발주 개소 목록 | 테이블로 개소 표시, 클릭 시 우측 상세 변경 |
| 품목 추가 폼 | 층/부호/사이즈/제품/수량 + 설정 입력 후 [+] 추가 |
| 엑셀 업로드 | [↑] 버튼으로 엑셀 일괄 업로드 |
| 상세 정보 | 선택 개소의 제품정보, 필수설정, 품목탭 |
| 견적 금액 요약 | 개소별 합계 + 상세별 합계 |
| 푸터 | 총 개소 수, 예상 견적금액, 견적 상태 |
| 버튼 | 견적서 산출, 임시저장, 최종저장 (미리보기 제외) |
---
## 파일 구조
### 테스트 페이지 (새로 생성)
```
src/app/[locale]/(protected)/sales/quote-management/
├── test-new/page.tsx ← 테스트 등록 페이지
├── test/[id]/page.tsx ← 테스트 상세 페이지
└── test/[id]/edit/page.tsx ← 테스트 수정 페이지
```
### 컴포넌트 (새로 생성)
```
src/components/quotes/
├── QuoteRegistrationV2.tsx ← 메인 컴포넌트 (새 UI)
├── LocationListPanel.tsx ← 왼쪽: 발주 개소 목록 + 추가 폼
├── LocationDetailPanel.tsx ← 오른쪽: 선택 개소 상세
├── QuoteSummaryPanel.tsx ← 견적 금액 요약
├── QuoteFooterBar.tsx ← 하단 푸터 바
└── ExcelUploadButton.tsx ← 엑셀 업로드/다운로드
```
---
## 작업 체크리스트
### Phase 1: 기본 구조 설정
- [ ] 테스트 등록 페이지 생성 (test-new/page.tsx)
- [ ] 테스트 상세 페이지 생성 (test/[id]/page.tsx)
- [ ] 테스트 수정 페이지 생성 (test/[id]/edit/page.tsx)
- [ ] /dev/test-urls에 테스트 URL 추가
### Phase 2: 핵심 컴포넌트 구현
- [ ] QuoteRegistrationV2.tsx 메인 컴포넌트 생성
- [ ] LocationListPanel.tsx 발주 개소 목록 구현
- [ ] LocationDetailPanel.tsx 상세 정보 구현
- [ ] QuoteSummaryPanel.tsx 금액 요약 구현
- [ ] QuoteFooterBar.tsx 푸터 바 구현
### Phase 3: 상세 기능 구현
- [ ] 개소 선택 시 우측 상세 변경 기능
- [ ] 품목 추가 폼 기능
- [ ] 탭 전환 기능 (본체, 철골품 등)
- [ ] 품목 테이블 표시
### Phase 4: 엑셀 기능
- [ ] ExcelUploadButton.tsx 컴포넌트 생성
- [ ] 엑셀 양식 다운로드 기능
- [ ] 엑셀 업로드 및 파싱 기능
### Phase 5: 버튼 및 저장 기능
- [ ] 견적서 산출 버튼 기능
- [ ] 임시저장 버튼 기능
- [ ] 최종저장 버튼 기능
---
## 참고 사항
### 기존 파일 (수정 금지)
- `src/app/[locale]/(protected)/sales/quote-management/page.tsx` (목록)
- `src/app/[locale]/(protected)/sales/quote-management/new/page.tsx` (등록)
- `src/app/[locale]/(protected)/sales/quote-management/[id]/page.tsx` (상세)
- `src/app/[locale]/(protected)/sales/quote-management/[id]/edit/page.tsx` (수정)
- `src/components/quotes/QuoteRegistration.tsx` (기존 컴포넌트)
### 재사용 가능 파일
- `src/components/quotes/actions.ts` (API 호출)
- `src/components/quotes/QuoteDocument.tsx` (견적서 문서)
- `src/components/quotes/types.ts` (타입 정의)
### 디자인 원칙
- 내용/기능: 스크린샷 충실히 구현
- 스타일/레이아웃: 기존 프로젝트 패턴 따르기
- 색상: 주황색 헤더, 노란색 배경 등 스크린샷 참고
---
## 진행 상태
- 시작일: 2026-01-12
- 현재 상태: 계획 수립 완료

View File

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

View File

@@ -58,10 +58,23 @@ http://localhost:3000/ko/hr/attendance # 🧪 모바일 출퇴근 (테스트)
| 견적관리 | `/ko/sales/quote-management` | ✅ |
| 단가관리 | `/ko/sales/pricing-management` | ✅ |
### 견적 V2 테스트 (새 UI)
| 페이지 | URL | 상태 |
|--------|-----|------|
| **견적 등록 (V2)** | `/ko/sales/quote-management/test-new` | 🧪 테스트 |
| **견적 상세 (V2)** | `/ko/sales/quote-management/test/1` | 🧪 테스트 |
| **견적 수정 (V2)** | `/ko/sales/quote-management/test/1/edit` | 🧪 테스트 |
```
http://localhost:3000/ko/sales/client-management-sales-admin
http://localhost:3000/ko/sales/quote-management
http://localhost:3000/ko/sales/pricing-management
# 견적 V2 테스트 (새 UI)
http://localhost:3000/ko/sales/quote-management/test-new # 🧪 견적 등록 V2
http://localhost:3000/ko/sales/quote-management/test/1 # 🧪 견적 상세 V2
http://localhost:3000/ko/sales/quote-management/test/1/edit # 🧪 견적 수정 V2
```
---

View File

@@ -1,5 +1,5 @@
# Juil Enterprise Test URLs
Last Updated: 2026-01-05
Last Updated: 2026-01-12
### 대시보드
| 페이지 | URL | 상태 |
@@ -7,10 +7,11 @@ Last Updated: 2026-01-05
| **메인 대시보드** | `/ko/construction/dashboard` | ✅ 완료 |
## 프로젝트 관리 (Project)
### 메인
### 프로젝트관리 (Management)
| 페이지 | URL | 상태 |
|---|---|---|
| **프로젝트 관리 메인** | `/ko/construction/project` | 🚧 구조잡기 |
| **프로젝트 관리** | `/ko/construction/project/management` | ✅ 완료 |
### 입찰관리 (Bidding)
| 페이지 | URL | 상태 |
@@ -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` | ✅ 완료 |

View File

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

@@ -52,6 +52,7 @@
"sonner": "^2.0.7",
"tailwind-merge": "^3.3.1",
"vaul": "^1.1.2",
"xlsx": "^0.18.5",
"zod": "^4.1.12",
"zustand": "^5.0.9"
},
@@ -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",

View File

@@ -57,6 +57,7 @@
"sonner": "^2.0.7",
"tailwind-merge": "^3.3.1",
"vaul": "^1.1.2",
"xlsx": "^0.18.5",
"zod": "^4.1.12",
"zustand": "^5.0.9"
},

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,45 @@
'use client';
import { useEffect, useState } from 'react';
import { useSearchParams } from 'next/navigation';
import ContractDetailForm from '@/components/business/construction/contract/ContractDetailForm';
import { getContractDetail } from '@/components/business/construction/contract';
import type { ContractDetail } from '@/components/business/construction/contract/types';
export default function ContractCreatePage() {
const searchParams = useSearchParams();
const baseContractId = searchParams.get('baseContractId');
const [baseData, setBaseData] = useState<ContractDetail | undefined>(undefined);
const [isLoading, setIsLoading] = useState(!!baseContractId);
useEffect(() => {
if (baseContractId) {
// 변경 계약서 생성: 기존 계약 데이터 복사
getContractDetail(baseContractId)
.then(result => {
if (result.success && result.data) {
setBaseData(result.data);
}
})
.finally(() => setIsLoading(false));
}
}, [baseContractId]);
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<div className="text-muted-foreground"> ...</div>
</div>
);
}
return (
<ContractDetailForm
mode="create"
contractId=""
initialData={baseData}
isChangeContract={!!baseContractId}
/>
);
}

View File

@@ -0,0 +1,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" />;
}

View File

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

View File

@@ -0,0 +1,7 @@
'use client';
import IssueDetailForm from '@/components/business/construction/issue-management/IssueDetailForm';
export default function IssueNewPage() {
return <IssueDetailForm mode="create" />;
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,5 @@
import { UtilityManagementListClient } from '@/components/business/construction/utility-management';
export default function UtilityManagementPage() {
return <UtilityManagementListClient />;
}

View File

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

View File

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

View File

@@ -1,13 +1,14 @@
'use client';
import React, { useState, useMemo } from 'react';
import { ChevronDown, ChevronRight, Check, Search, X } from 'lucide-react';
import { ChevronDown, ChevronRight, Check } from 'lucide-react';
import { cn } from '@/lib/utils';
import type { ChecklistCategory, ChecklistSubItem } from '../types';
interface Day1ChecklistPanelProps {
categories: ChecklistCategory[];
selectedSubItemId: string | null;
searchTerm: string;
onSubItemSelect: (categoryId: string, subItemId: string) => void;
onSubItemToggle: (categoryId: string, subItemId: string, isCompleted: boolean) => void;
}
@@ -15,13 +16,13 @@ interface Day1ChecklistPanelProps {
export function Day1ChecklistPanel({
categories,
selectedSubItemId,
searchTerm,
onSubItemSelect,
onSubItemToggle,
}: Day1ChecklistPanelProps) {
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(
new Set(categories.map(c => c.id)) // 기본적으로 모두 펼침
);
const [searchTerm, setSearchTerm] = useState('');
// 검색 필터링된 카테고리
const filteredCategories = useMemo(() => {
@@ -74,10 +75,6 @@ export function Day1ChecklistPanel({
return { completed, total: originalCategory.subItems.length };
};
const clearSearch = () => {
setSearchTerm('');
};
// 검색 결과 하이라이트
const highlightText = (text: string, term: string) => {
if (!term.trim()) return text;
@@ -96,29 +93,9 @@ export function Day1ChecklistPanel({
return (
<div className="bg-white rounded-lg border border-gray-200 overflow-hidden h-full flex flex-col">
{/* 헤더 + 검색 */}
{/* 헤더 */}
<div className="bg-gray-100 px-2 sm:px-4 py-2 sm:py-3 border-b border-gray-200">
<h3 className="font-semibold text-gray-900 text-sm sm:text-base mb-2"> </h3>
{/* 검색 입력 */}
<div className="relative">
<Search className="absolute left-2 sm:left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
<input
type="text"
placeholder="항목 검색..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full pl-8 sm:pl-9 pr-8 py-1.5 sm:py-2 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
{searchTerm && (
<button
type="button"
onClick={clearSearch}
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 hover:bg-gray-200 rounded-full transition-colors"
>
<X className="h-4 w-4 text-gray-400" />
</button>
)}
</div>
<h3 className="font-semibold text-gray-900 text-sm sm:text-base"> </h3>
{/* 검색 결과 카운트 */}
{searchTerm && (
<div className="mt-1.5 sm:mt-2 text-xs text-gray-500">

View File

@@ -270,6 +270,16 @@ export default function QualityInspectionPage() {
</div>
)}
{/* 공통 필터 (1일차/2일차 모두 사용) */}
<Filters
selectedYear={selectedYear}
selectedQuarter={selectedQuarter}
searchTerm={searchTerm}
onYearChange={handleYearChange}
onQuarterChange={handleQuarterChange}
onSearchChange={handleSearchChange}
/>
{activeDay === 1 ? (
// ===== 1일차: 기준/매뉴얼 심사 =====
<div className="flex-1 grid grid-cols-12 gap-3 sm:gap-4 lg:min-h-0">
@@ -284,6 +294,7 @@ export default function QualityInspectionPage() {
<Day1ChecklistPanel
categories={filteredDay1Categories}
selectedSubItemId={selectedSubItemId}
searchTerm={searchTerm}
onSubItemSelect={handleSubItemSelect}
onSubItemToggle={handleSubItemToggle}
/>
@@ -315,17 +326,7 @@ export default function QualityInspectionPage() {
</div>
) : (
// ===== 2일차: 로트추적 심사 =====
<>
<Filters
selectedYear={selectedYear}
selectedQuarter={selectedQuarter}
searchTerm={searchTerm}
onYearChange={handleYearChange}
onQuarterChange={handleQuarterChange}
onSearchChange={handleSearchChange}
/>
<div className="flex-1 grid grid-cols-12 gap-3 sm:gap-4 lg:gap-6 lg:min-h-0">
<div className="flex-1 grid grid-cols-12 gap-3 sm:gap-4 lg:gap-6 lg:min-h-0">
<div className="col-span-12 lg:col-span-3 min-h-[250px] sm:min-h-[300px] lg:min-h-0 lg:h-full overflow-auto lg:overflow-hidden">
<ReportList
reports={filteredReports}
@@ -352,7 +353,6 @@ export default function QualityInspectionPage() {
/>
</div>
</div>
</>
)}
{/* 설정 패널 */}

View File

@@ -0,0 +1,54 @@
/**
* 견적 등록 테스트 페이지 (V2 UI)
*
* 새로운 자동 견적 산출 UI 테스트용
* 기존 견적 등록 페이지는 수정하지 않음
*/
"use client";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { QuoteRegistrationV2, QuoteFormDataV2 } from "@/components/quotes/QuoteRegistrationV2";
import { toast } from "sonner";
export default function QuoteTestNewPage() {
const router = useRouter();
const [isSaving, setIsSaving] = useState(false);
const handleBack = () => {
router.push("/sales/quote-management");
};
const handleSave = async (data: QuoteFormDataV2, saveType: "temporary" | "final") => {
setIsSaving(true);
try {
// TODO: API 연동 시 실제 저장 로직 구현
console.log("[테스트] 저장 데이터:", data);
console.log("[테스트] 저장 타입:", saveType);
// 테스트용 지연
await new Promise((resolve) => setTimeout(resolve, 1000));
toast.success(`[테스트] ${saveType === "temporary" ? "임시" : "최종"} 저장 완료`);
// 저장 후 상세 페이지로 이동 (테스트용으로 ID=1 사용)
if (saveType === "final") {
router.push("/sales/quote-management/test/1");
}
} catch (error) {
toast.error("저장 중 오류가 발생했습니다.");
} finally {
setIsSaving(false);
}
};
return (
<QuoteRegistrationV2
mode="create"
onBack={handleBack}
onSave={handleSave}
isLoading={isSaving}
/>
);
}

View File

@@ -0,0 +1,152 @@
/**
* 견적 수정 테스트 페이지 (V2 UI)
*
* 새로운 자동 견적 산출 UI 테스트용
* 기존 견적 수정 페이지는 수정하지 않음
*/
"use client";
import { useRouter, useParams } from "next/navigation";
import { useState, useEffect } from "react";
import { QuoteRegistrationV2, QuoteFormDataV2 } from "@/components/quotes/QuoteRegistrationV2";
import { ContentLoadingSpinner } from "@/components/ui/loading-spinner";
import { toast } from "sonner";
// 테스트용 목업 데이터
const MOCK_DATA: QuoteFormDataV2 = {
id: "1",
registrationDate: "2026-01-12",
writer: "드미트리",
clientId: "1",
clientName: "아크다이레드",
siteName: "강남 테스트 현장",
manager: "김담당",
contact: "010-1234-5678",
dueDate: "2026-02-01",
remarks: "테스트 비고 내용입니다.",
status: "draft",
locations: [
{
id: "loc-1",
floor: "1층",
code: "FSS-01",
openWidth: 5000,
openHeight: 3000,
productCode: "KSS01",
productName: "방화스크린",
quantity: 1,
guideRailType: "wall",
motorPower: "single",
controller: "basic",
wingSize: 50,
inspectionFee: 50000,
unitPrice: 1645200,
totalPrice: 1645200,
},
{
id: "loc-2",
floor: "3층",
code: "FST-30",
openWidth: 7500,
openHeight: 3300,
productCode: "KSS02",
productName: "방화스크린2",
quantity: 1,
guideRailType: "wall",
motorPower: "single",
controller: "smart",
wingSize: 50,
inspectionFee: 50000,
unitPrice: 2589198,
totalPrice: 2589198,
},
{
id: "loc-3",
floor: "5층",
code: "FSS-50",
openWidth: 6000,
openHeight: 2800,
productCode: "KSS01",
productName: "방화스크린",
quantity: 2,
guideRailType: "floor",
motorPower: "three",
controller: "premium",
wingSize: 50,
inspectionFee: 50000,
unitPrice: 1721214,
totalPrice: 3442428,
},
],
};
export default function QuoteTestEditPage() {
const router = useRouter();
const params = useParams();
const quoteId = params.id as string;
const [quote, setQuote] = useState<QuoteFormDataV2 | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
useEffect(() => {
// 테스트용 데이터 로드 시뮬레이션
const loadQuote = async () => {
setIsLoading(true);
try {
// 실제로는 API 호출
await new Promise((resolve) => setTimeout(resolve, 500));
setQuote({ ...MOCK_DATA, id: quoteId });
} catch (error) {
toast.error("견적 정보를 불러오는데 실패했습니다.");
router.push("/sales/quote-management");
} finally {
setIsLoading(false);
}
};
loadQuote();
}, [quoteId, router]);
const handleBack = () => {
router.push("/sales/quote-management");
};
const handleSave = async (data: QuoteFormDataV2, saveType: "temporary" | "final") => {
setIsSaving(true);
try {
// TODO: API 연동 시 실제 저장 로직 구현
console.log("[테스트] 수정 데이터:", data);
console.log("[테스트] 저장 타입:", saveType);
// 테스트용 지연
await new Promise((resolve) => setTimeout(resolve, 1000));
toast.success(`[테스트] ${saveType === "temporary" ? "임시" : "최종"} 저장 완료`);
// 저장 후 상세 페이지로 이동
if (saveType === "final") {
router.push(`/sales/quote-management/test/${quoteId}`);
}
} catch (error) {
toast.error("저장 중 오류가 발생했습니다.");
} finally {
setIsSaving(false);
}
};
if (isLoading) {
return <ContentLoadingSpinner text="견적 정보를 불러오는 중..." />;
}
return (
<QuoteRegistrationV2
mode="edit"
onBack={handleBack}
onSave={handleSave}
initialData={quote}
isLoading={isSaving}
/>
);
}

View File

@@ -0,0 +1,126 @@
/**
* 견적 상세 테스트 페이지 (V2 UI)
*
* 새로운 자동 견적 산출 UI 테스트용
* 기존 견적 상세 페이지는 수정하지 않음
*/
"use client";
import { useRouter, useParams } from "next/navigation";
import { useState, useEffect } from "react";
import { QuoteRegistrationV2, QuoteFormDataV2, LocationItem } from "@/components/quotes/QuoteRegistrationV2";
import { ContentLoadingSpinner } from "@/components/ui/loading-spinner";
import { toast } from "sonner";
// 테스트용 목업 데이터
const MOCK_DATA: QuoteFormDataV2 = {
id: "1",
registrationDate: "2026-01-12",
writer: "드미트리",
clientId: "1",
clientName: "아크다이레드",
siteName: "강남 테스트 현장",
manager: "김담당",
contact: "010-1234-5678",
dueDate: "2026-02-01",
remarks: "테스트 비고 내용입니다.",
status: "draft",
locations: [
{
id: "loc-1",
floor: "1층",
code: "FSS-01",
openWidth: 5000,
openHeight: 3000,
productCode: "KSS01",
productName: "방화스크린",
quantity: 1,
guideRailType: "wall",
motorPower: "single",
controller: "basic",
wingSize: 50,
inspectionFee: 50000,
unitPrice: 1645200,
totalPrice: 1645200,
},
{
id: "loc-2",
floor: "3층",
code: "FST-30",
openWidth: 7500,
openHeight: 3300,
productCode: "KSS02",
productName: "방화스크린2",
quantity: 1,
guideRailType: "wall",
motorPower: "single",
controller: "smart",
wingSize: 50,
inspectionFee: 50000,
unitPrice: 2589198,
totalPrice: 2589198,
},
{
id: "loc-3",
floor: "5층",
code: "FSS-50",
openWidth: 6000,
openHeight: 2800,
productCode: "KSS01",
productName: "방화스크린",
quantity: 2,
guideRailType: "floor",
motorPower: "three",
controller: "premium",
wingSize: 50,
inspectionFee: 50000,
unitPrice: 1721214,
totalPrice: 3442428,
},
],
};
export default function QuoteTestDetailPage() {
const router = useRouter();
const params = useParams();
const quoteId = params.id as string;
const [quote, setQuote] = useState<QuoteFormDataV2 | null>(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
// 테스트용 데이터 로드 시뮬레이션
const loadQuote = async () => {
setIsLoading(true);
try {
// 실제로는 API 호출
await new Promise((resolve) => setTimeout(resolve, 500));
setQuote({ ...MOCK_DATA, id: quoteId });
} catch (error) {
toast.error("견적 정보를 불러오는데 실패했습니다.");
router.push("/sales/quote-management");
} finally {
setIsLoading(false);
}
};
loadQuote();
}, [quoteId, router]);
const handleBack = () => {
router.push("/sales/quote-management");
};
if (isLoading) {
return <ContentLoadingSpinner text="견적 정보를 불러오는 중..." />;
}
return (
<QuoteRegistrationV2
mode="view"
onBack={handleBack}
initialData={quote}
/>
);
}

View File

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

View File

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

View File

@@ -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="당월 예상 지출 내역"

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,5 @@
export { TodayIssueSection } from './TodayIssueSection';
export { StatusBoardSection } from './StatusBoardSection';
export { DailyReportSection } from './DailyReportSection';
export { MonthlyExpenseSection } from './MonthlyExpenseSection';
export { CardManagementSection } from './CardManagementSection';

View File

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

View File

@@ -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="입찰번호, 거래처, 현장명 검색"

View File

@@ -36,7 +36,7 @@ import {
getEmptyContractFormData,
contractDetailToFormData,
} from './types';
import { updateContract, deleteContract } from './actions';
import { updateContract, deleteContract, createContract } from './actions';
import { downloadFileById } from '@/lib/utils/fileDownload';
import { ContractDocumentModal } from './modals/ContractDocumentModal';
import {
@@ -59,19 +59,22 @@ function formatFileSize(bytes: number): string {
}
interface ContractDetailFormProps {
mode: 'view' | 'edit';
mode: 'view' | 'edit' | 'create';
contractId: string;
initialData?: ContractDetail;
isChangeContract?: boolean; // 변경 계약서 생성 여부
}
export default function ContractDetailForm({
mode,
contractId,
initialData,
isChangeContract = false,
}: ContractDetailFormProps) {
const router = useRouter();
const isViewMode = mode === 'view';
const isEditMode = mode === 'edit';
const isCreateMode = mode === 'create';
// 폼 데이터
const [formData, setFormData] = useState<ContractFormData>(
@@ -121,10 +124,19 @@ export default function ContractDetailForm({
router.push(`/ko/construction/project/contract/${contractId}/edit`);
}, [router, contractId]);
const handleCancel = useCallback(() => {
router.push(`/ko/construction/project/contract/${contractId}`);
// 변경 계약서 생성 핸들러
const handleCreateChangeContract = useCallback(() => {
router.push(`/ko/construction/project/contract/create?baseContractId=${contractId}`);
}, [router, contractId]);
const handleCancel = useCallback(() => {
if (isCreateMode) {
router.push('/ko/construction/project/contract');
} else {
router.push(`/ko/construction/project/contract/${contractId}`);
}
}, [router, contractId, isCreateMode]);
// 폼 필드 변경
const handleFieldChange = useCallback(
(field: keyof ContractFormData, value: string | number) => {
@@ -141,14 +153,28 @@ export default function ContractDetailForm({
const handleConfirmSave = useCallback(async () => {
setIsLoading(true);
try {
const result = await updateContract(contractId, formData);
if (result.success) {
toast.success('수정이 완료되었습니다.');
setShowSaveDialog(false);
router.push(`/ko/construction/project/contract/${contractId}`);
router.refresh();
if (isCreateMode) {
// 새 계약 생성 (변경 계약서 포함)
const result = await createContract(formData);
if (result.success && result.data) {
toast.success(isChangeContract ? '변경 계약서가 생성되었습니다.' : '계약이 생성되었습니다.');
setShowSaveDialog(false);
router.push(`/ko/construction/project/contract/${result.data.id}`);
router.refresh();
} else {
toast.error(result.error || '저장에 실패했습니다.');
}
} else {
toast.error(result.error || '저장에 실패했습니다.');
// 기존 계약 수정
const result = await updateContract(contractId, formData);
if (result.success) {
toast.success('수정이 완료되었습니다.');
setShowSaveDialog(false);
router.push(`/ko/construction/project/contract/${contractId}`);
router.refresh();
} else {
toast.error(result.error || '저장에 실패했습니다.');
}
}
} catch (error) {
if (isNextRedirectError(error)) throw error;
@@ -156,7 +182,7 @@ export default function ContractDetailForm({
} finally {
setIsLoading(false);
}
}, [router, contractId, formData]);
}, [router, contractId, formData, isCreateMode, isChangeContract]);
// 삭제 핸들러
const handleDelete = useCallback(() => {
@@ -280,6 +306,9 @@ export default function ContractDetailForm({
// 헤더 액션 버튼
const headerActions = isViewMode ? (
<div className="flex items-center gap-2">
<Button variant="outline" onClick={handleCreateChangeContract}>
</Button>
<Button variant="outline" onClick={handleViewDocument}>
<Eye className="h-4 w-4 mr-2" />
@@ -289,6 +318,15 @@ export default function ContractDetailForm({
</Button>
<Button onClick={handleEdit}></Button>
</div>
) : isCreateMode ? (
<div className="flex items-center gap-2">
<Button variant="outline" onClick={handleCancel}>
</Button>
<Button onClick={handleSave} disabled={isLoading}>
</Button>
</div>
) : (
<div className="flex items-center gap-2">
<Button variant="outline" onClick={handleCancel}>
@@ -303,10 +341,15 @@ export default function ContractDetailForm({
</div>
);
// 페이지 타이틀
const pageTitle = isCreateMode
? (isChangeContract ? '변경 계약서 생성' : '계약 등록')
: '계약 상세';
return (
<PageLayout>
<PageHeader
title="계약 상세"
title={pageTitle}
description="계약 정보를 관리합니다"
icon={FileText}
onBack={handleBack}
@@ -483,8 +526,8 @@ export default function ContractDetailForm({
</CardHeader>
<CardContent>
<div className="space-y-3">
{/* 파일 선택 버튼 (수정 모드에서만) */}
{isEditMode && (
{/* 파일 선택 버튼 (수정/생성 모드에서만) */}
{(isEditMode || isCreateMode) && (
<Button variant="outline" onClick={handleContractFileSelect}>
</Button>
@@ -498,7 +541,7 @@ export default function ContractDetailForm({
<span className="text-sm font-medium">{formData.contractFile.name}</span>
<span className="text-xs text-blue-600">( )</span>
</div>
{isEditMode && (
{(isEditMode || isCreateMode) && (
<Button
variant="ghost"
size="icon"
@@ -526,7 +569,7 @@ export default function ContractDetailForm({
<Download className="h-4 w-4 mr-1" />
</Button>
{isEditMode && (
{(isEditMode || isCreateMode) && (
<Button
variant="ghost"
size="icon"
@@ -562,7 +605,7 @@ export default function ContractDetailForm({
</CardHeader>
<CardContent>
{/* 드래그 앤 드롭 영역 */}
{isEditMode && (
{(isEditMode || isCreateMode) && (
<div
className={`border-2 border-dashed rounded-lg p-8 text-center mb-4 transition-colors cursor-pointer ${
isDragging ? 'border-primary bg-primary/5' : 'border-muted-foreground/25'
@@ -605,7 +648,7 @@ export default function ContractDetailForm({
<Download className="h-4 w-4 mr-1" />
</Button>
{isEditMode && (
{(isEditMode || isCreateMode) && (
<Button
variant="ghost"
size="icon"

View File

@@ -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="계약번호, 거래처, 현장명 검색"

View File

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

View File

@@ -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="견적번호, 거래처, 현장명 검색"

View File

@@ -88,6 +88,8 @@ export function EstimateDocumentModal({
address: '주소',
amount: formData.summaryItems.reduce((sum, item) => sum + item.totalCost, 0),
date: formData.bidInfo.bidDate || '2025년 12월 12일',
manager: formData.estimateCompanyManager || '',
managerContact: formData.estimateCompanyManagerContact || '',
contact: {
hp: '010-3679-2188',
tel: '(02) 849-5130',
@@ -194,17 +196,22 @@ export function EstimateDocumentModal({
<td className="border border-gray-400 px-3 py-2">{documentData.date}</td>
</tr>
<tr>
<td className="border border-gray-400 px-3 py-2 bg-gray-100 text-center"></td>
<td className="border border-gray-400 px-3 py-2 bg-gray-100 text-center"></td>
<td className="border border-gray-400 px-3 py-2"></td>
<td className="border border-gray-400 px-3 py-2 bg-gray-100 text-center"></td>
<td className="border border-gray-400 px-3 py-2">
<td className="border border-gray-400 px-3 py-2 bg-gray-100 text-center" rowSpan={2}></td>
<td className="border border-gray-400 px-3 py-2" rowSpan={2}>
<div className="space-y-0.5 text-xs">
<div> : {documentData.manager}</div>
<div>H . P : {documentData.contact.hp}</div>
<div>T E L : {documentData.contact.tel}</div>
<div>F A X : {documentData.contact.fax}</div>
</div>
</td>
</tr>
<tr>
<td className="border border-gray-400 px-3 py-2 bg-gray-100 text-center"></td>
<td className="border border-gray-400 px-3 py-2"></td>
</tr>
</tbody>
</table>

View File

@@ -53,29 +53,46 @@ export function EstimateInfoSection({
<CardHeader>
<CardTitle className="text-lg"> </CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<div className="space-y-2">
<Label className="text-sm font-medium text-gray-700"></Label>
<Input value={formData.estimateCode} disabled className="bg-gray-50" />
<CardContent className="space-y-4">
{/* 1행: 견적번호, 견적자 */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label className="text-sm font-medium text-gray-700"></Label>
<Input value={formData.estimateCode} disabled className="bg-gray-50" />
</div>
<div className="space-y-2">
<Label className="text-sm font-medium text-gray-700"></Label>
<Input value={formData.estimatorName} disabled className="bg-gray-50" />
</div>
</div>
<div className="space-y-2">
<Label className="text-sm font-medium text-gray-700"></Label>
<Input value={formData.estimatorName} disabled className="bg-gray-50" />
{/* 2행: 견적 회사 담당자, 견적 회사 담당자 연락처 */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label className="text-sm font-medium text-gray-700"> </Label>
<Input value={formData.estimateCompanyManager} disabled className="bg-gray-50" />
</div>
<div className="space-y-2">
<Label className="text-sm font-medium text-gray-700"> </Label>
<Input value={formData.estimateCompanyManagerContact} disabled className="bg-gray-50" />
</div>
</div>
<div className="space-y-2">
<Label className="text-sm font-medium text-gray-700"></Label>
<Input
value={formatAmount(formData.estimateAmount)}
disabled
className="bg-gray-50 text-right"
/>
</div>
<div className="space-y-2">
<Label className="text-sm font-medium text-gray-700"></Label>
<div className="flex items-center h-10 px-3 border rounded-md bg-gray-50">
<span className={STATUS_STYLES[formData.status]}>
{STATUS_LABELS[formData.status]}
</span>
{/* 3행: 견적금액, 상태 */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label className="text-sm font-medium text-gray-700"></Label>
<Input
value={formatAmount(formData.estimateAmount)}
disabled
className="bg-gray-50 text-right"
/>
</div>
<div className="space-y-2">
<Label className="text-sm font-medium text-gray-700"></Label>
<div className="flex items-center h-10 px-3 border rounded-md bg-gray-50">
<span className={STATUS_STYLES[formData.status]}>
{STATUS_LABELS[formData.status]}
</span>
</div>
</div>
</div>
</CardContent>

View File

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

View File

@@ -184,6 +184,8 @@ export interface EstimateDetailFormData {
estimateCode: string;
estimatorId: string;
estimatorName: string;
estimateCompanyManager: string; // 견적 회사 담당자
estimateCompanyManagerContact: string; // 견적 회사 담당자 연락처
estimateAmount: number;
status: EstimateStatus;
@@ -251,6 +253,8 @@ export function getEmptyEstimateDetailFormData(): EstimateDetailFormData {
estimateCode: '',
estimatorId: '',
estimatorName: '',
estimateCompanyManager: '',
estimateCompanyManagerContact: '',
estimateAmount: 0,
status: 'pending',
siteBriefing: {
@@ -290,6 +294,8 @@ export function estimateDetailToFormData(detail: EstimateDetail): EstimateDetail
estimateCode: detail.estimateCode,
estimatorId: detail.estimatorId,
estimatorName: detail.estimatorName,
estimateCompanyManager: detail.estimateCompanyManager || '',
estimateCompanyManagerContact: detail.estimateCompanyManagerContact || '',
estimateAmount: detail.estimateAmount,
status: detail.status,
siteBriefing: detail.siteBriefing,
@@ -315,6 +321,8 @@ export interface Estimate {
projectName: string; // 현장명
estimatorId: string; // 견적자 ID
estimatorName: string; // 견적자명
estimateCompanyManager: string; // 견적 회사 담당자
estimateCompanyManagerContact: string; // 견적 회사 담당자 연락처
// 견적 정보
itemCount: number; // 총 개소 (품목 수)

View File

@@ -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="보고서번호, 거래처, 현장명 검색"

View File

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

View File

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

View 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: '이슈 일괄 철회에 실패했습니다.' };
}
}

View File

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

View 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: '기타' },
];

View File

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

View File

@@ -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="품목명, 품목번호, 카테고리 검색"

View File

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

View File

@@ -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="노임번호, 구분 검색"

View File

@@ -3,6 +3,8 @@
// 구분 옵션
export const CATEGORY_OPTIONS = [
{ value: 'all', label: '전체' },
{ value: '작업반장', label: '작업반장' },
{ value: '작업자', label: '작업자' },
{ value: '가로', label: '가로' },
{ value: '세로할증', label: '세로할증' },
] as const;

View File

@@ -1,7 +1,7 @@
// 노임관리 타입 정의
// 구분 타입
export type LaborCategory = '가로' | '세로할증';
export type LaborCategory = '작업반장' | '작업자' | '가로' | '세로할증';
// 상태 타입
export type LaborStatus = '사용' | '중지';

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,366 @@
'use client';
import { useMemo, useRef, useEffect, useState } from 'react';
import { ChevronLeft, ChevronRight } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import type { Project, ChartViewMode } from './types';
import { GANTT_BAR_COLORS } from './types';
interface ProjectGanttChartProps {
projects: Project[];
viewMode: ChartViewMode;
currentDate: Date;
onProjectClick: (project: Project) => void;
onDateChange: (date: Date) => void;
}
export default function ProjectGanttChart({
projects,
viewMode,
currentDate,
onProjectClick,
onDateChange,
}: ProjectGanttChartProps) {
const scrollContainerRef = useRef<HTMLDivElement>(null);
const [isScrolling, setIsScrolling] = useState(false);
// 현재 날짜 기준으로 표시할 기간 계산
const { columns, startDate, endDate, yearGroups, monthGroups } = useMemo(() => {
const now = currentDate;
if (viewMode === 'day') {
// 일 모드: 현재 월의 1일~말일
const year = now.getFullYear();
const month = now.getMonth();
const daysInMonth = new Date(year, month + 1, 0).getDate();
const cols = Array.from({ length: daysInMonth }, (_, i) => ({
label: String(i + 1),
date: new Date(year, month, i + 1),
year,
month,
}));
return {
columns: cols,
startDate: new Date(year, month, 1),
endDate: new Date(year, month, daysInMonth),
yearGroups: null,
monthGroups: null,
};
} else if (viewMode === 'week') {
// 주 모드: 현재 월 기준 전후 2개월 (총 12주)
const year = now.getFullYear();
const month = now.getMonth();
// 전월 1일부터 시작
const startMonth = month === 0 ? 11 : month - 1;
const startYear = month === 0 ? year - 1 : year;
const periodStart = new Date(startYear, startMonth, 1);
// 다음월 말일까지
const endMonth = month === 11 ? 0 : month + 1;
const endYear = month === 11 ? year + 1 : year;
const periodEnd = new Date(endYear, endMonth + 1, 0);
// 주차별 컬럼 생성 (월요일 시작)
const cols: { label: string; date: Date; year: number; month: number; weekStart: Date; weekEnd: Date }[] = [];
const tempDate = new Date(periodStart);
// 첫 번째 월요일 찾기
while (tempDate.getDay() !== 1) {
tempDate.setDate(tempDate.getDate() + 1);
}
let weekNum = 1;
while (tempDate <= periodEnd) {
const weekStart = new Date(tempDate);
const weekEnd = new Date(tempDate);
weekEnd.setDate(weekEnd.getDate() + 6);
cols.push({
label: `${weekNum}`,
date: new Date(tempDate),
year: tempDate.getFullYear(),
month: tempDate.getMonth(),
weekStart,
weekEnd,
});
tempDate.setDate(tempDate.getDate() + 7);
weekNum++;
}
// 월별 그룹 계산
const monthGroupsMap = new Map<string, number>();
cols.forEach((col) => {
const key = `${col.year}-${col.month}`;
monthGroupsMap.set(key, (monthGroupsMap.get(key) || 0) + 1);
});
const mGroups = Array.from(monthGroupsMap.entries()).map(([key, count]) => {
const [y, m] = key.split('-').map(Number);
return { year: y, month: m, count, label: `${m + 1}` };
});
return {
columns: cols,
startDate: cols[0]?.weekStart || periodStart,
endDate: cols[cols.length - 1]?.weekEnd || periodEnd,
yearGroups: null,
monthGroups: mGroups,
};
} else {
// 월 모드: 전년도 + 올해 (2년치, 24개월)
const year = now.getFullYear();
const prevYear = year - 1;
const cols: { label: string; date: Date; year: number; month: number }[] = [];
// 전년도 12개월
for (let i = 0; i < 12; i++) {
cols.push({
label: `${i + 1}`,
date: new Date(prevYear, i, 1),
year: prevYear,
month: i,
});
}
// 올해 12개월
for (let i = 0; i < 12; i++) {
cols.push({
label: `${i + 1}`,
date: new Date(year, i, 1),
year: year,
month: i,
});
}
return {
columns: cols,
startDate: new Date(prevYear, 0, 1),
endDate: new Date(year, 11, 31),
yearGroups: [
{ year: prevYear, count: 12 },
{ year: year, count: 12 },
],
monthGroups: null,
};
}
}, [viewMode, currentDate]);
// 막대 위치 및 너비 계산
const getBarStyle = (project: Project) => {
const projectStart = new Date(project.startDate);
const projectEnd = new Date(project.endDate);
// 범위 밖이면 표시 안함
if (projectEnd < startDate || projectStart > endDate) {
return null;
}
// 시작/종료 위치 계산
const totalDays = (endDate.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24);
const barStartDays = Math.max(0, (projectStart.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24));
const barEndDays = Math.min(totalDays, (projectEnd.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24));
const leftPercent = (barStartDays / totalDays) * 100;
const widthPercent = ((barEndDays - barStartDays) / totalDays) * 100;
// 색상 결정
let bgColor = GANTT_BAR_COLORS.in_progress;
if (project.status === 'completed') {
bgColor = GANTT_BAR_COLORS.completed;
} else if (project.hasUrgentIssue || project.status === 'urgent') {
bgColor = GANTT_BAR_COLORS.urgent;
}
return {
left: `${leftPercent}%`,
width: `${Math.max(widthPercent, 1)}%`,
backgroundColor: bgColor,
};
};
// 이전/다음 이동
const handlePrev = () => {
const newDate = new Date(currentDate);
if (viewMode === 'day') {
newDate.setMonth(newDate.getMonth() - 1);
} else if (viewMode === 'week') {
newDate.setMonth(newDate.getMonth() - 1);
} else {
newDate.setFullYear(newDate.getFullYear() - 1);
}
onDateChange(newDate);
};
const handleNext = () => {
const newDate = new Date(currentDate);
if (viewMode === 'day') {
newDate.setMonth(newDate.getMonth() + 1);
} else if (viewMode === 'week') {
newDate.setMonth(newDate.getMonth() + 1);
} else {
newDate.setFullYear(newDate.getFullYear() + 1);
}
onDateChange(newDate);
};
// 월 모드에서 올해 시작 위치로 스크롤
useEffect(() => {
if (scrollContainerRef.current && viewMode === 'month') {
// 올해 1월 위치로 스크롤 (전년도 12개월 건너뛰기)
const totalWidth = scrollContainerRef.current.scrollWidth;
const scrollPosition = totalWidth / 2 - scrollContainerRef.current.clientWidth / 3;
scrollContainerRef.current.scrollLeft = Math.max(0, scrollPosition);
} else if (scrollContainerRef.current && viewMode === 'day') {
const today = new Date();
const dayOfMonth = today.getDate();
const columnWidth = scrollContainerRef.current.scrollWidth / columns.length;
const scrollPosition = (dayOfMonth - 1) * columnWidth - scrollContainerRef.current.clientWidth / 2;
scrollContainerRef.current.scrollLeft = Math.max(0, scrollPosition);
}
}, [viewMode, columns.length]);
return (
<div className="border rounded-lg bg-card">
{/* 헤더: 날짜 네비게이션 */}
<div className="flex items-center justify-between p-3 border-b">
<div className="flex items-center gap-2">
<Button variant="outline" size="icon" onClick={handlePrev}>
<ChevronLeft className="h-4 w-4" />
</Button>
<span className="text-sm font-medium min-w-[140px] text-center">
{viewMode === 'day'
? `${currentDate.getFullYear()}${currentDate.getMonth() + 1}`
: viewMode === 'week'
? `${currentDate.getFullYear()}${currentDate.getMonth()}월 ~ ${currentDate.getMonth() + 2}`
: `${currentDate.getFullYear() - 1}년 ~ ${currentDate.getFullYear()}`}
</span>
<Button variant="outline" size="icon" onClick={handleNext}>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
{/* 범례 */}
<div className="flex items-center gap-4 text-xs">
<div className="flex items-center gap-1">
<div className="w-3 h-3 rounded" style={{ backgroundColor: GANTT_BAR_COLORS.in_progress }} />
<span></span>
</div>
<div className="flex items-center gap-1">
<div className="w-3 h-3 rounded" style={{ backgroundColor: GANTT_BAR_COLORS.completed }} />
<span></span>
</div>
<div className="flex items-center gap-1">
<div className="w-3 h-3 rounded" style={{ backgroundColor: GANTT_BAR_COLORS.urgent }} />
<span> </span>
</div>
</div>
</div>
{/* 차트 영역 */}
<div className="overflow-hidden">
<div
ref={scrollContainerRef}
className="overflow-x-auto"
onMouseDown={() => setIsScrolling(true)}
onMouseUp={() => setIsScrolling(false)}
onMouseLeave={() => setIsScrolling(false)}
>
<div className={cn(
viewMode === 'month' ? 'min-w-[1600px]' : viewMode === 'week' ? 'min-w-[1000px]' : 'min-w-[800px]'
)}>
{/* 전체를 하나의 세로 스크롤 영역으로 */}
<div className="max-h-[450px] overflow-y-auto">
{/* 연도 헤더 (월 모드에서만) */}
{viewMode === 'month' && yearGroups && (
<div className="flex bg-muted/50 sticky top-0 z-20">
{yearGroups.map((group) => (
<div
key={group.year}
className="flex-1 p-1.5 text-xs font-semibold text-center border-r border-border last:border-r-0"
style={{ flex: group.count }}
>
{group.year}
</div>
))}
</div>
)}
{/* 월 헤더 (주 모드에서만) */}
{viewMode === 'week' && monthGroups && (
<div className="flex bg-muted/50 sticky top-0 z-20">
{monthGroups.map((group, idx) => (
<div
key={`${group.year}-${group.month}-${idx}`}
className="flex-1 p-1.5 text-xs font-semibold text-center border-r border-border last:border-r-0"
style={{ flex: group.count }}
>
{group.label}
</div>
))}
</div>
)}
{/* 컬럼 헤더 - 날짜/주/월 */}
<div className={cn(
'flex bg-muted/30 sticky z-10',
(viewMode === 'month' || viewMode === 'week') ? 'top-[30px]' : 'top-0'
)}>
{columns.map((col, idx) => (
<div
key={idx}
className={cn(
'flex-1 p-2 text-xs text-center border-r border-border last:border-r-0',
viewMode === 'day' && col.date.getDay() === 0 && 'text-red-500',
viewMode === 'day' && col.date.getDay() === 6 && 'text-blue-500'
)}
>
{col.label}
</div>
))}
</div>
{/* 프로젝트 행들 (가로선 없음) */}
{projects.length === 0 ? (
<div className="p-8 text-center text-muted-foreground">
.
</div>
) : (
projects.map((project) => {
const barStyle = getBarStyle(project);
return (
<div
key={project.id}
className="relative h-12 hover:bg-muted/10 cursor-pointer"
onClick={() => !isScrolling && onProjectClick(project)}
>
{/* 그리드 세로선 */}
<div className="absolute inset-0 flex">
{columns.map((_, idx) => (
<div key={idx} className="flex-1 border-r border-border last:border-r-0" />
))}
</div>
{/* 막대 - 프로젝트명 직접 표시 */}
{barStyle && (
<div
className="absolute top-1/2 -translate-y-1/2 h-7 rounded text-xs text-white flex items-center px-2 truncate shadow-sm"
style={barStyle}
>
<span className="truncate font-medium">
[{project.partnerName}] {project.siteName} {project.progressRate}%
</span>
</div>
)}
</div>
);
})
)}
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,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>
);
}

View File

@@ -0,0 +1,661 @@
'use client';
import { useState, useMemo, useCallback, useEffect, Fragment } from 'react';
import { useRouter } from 'next/navigation';
import { FolderKanban, Pencil, ClipboardList, PlayCircle, CheckCircle2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Checkbox } from '@/components/ui/checkbox';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent } from '@/components/ui/card';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { PageLayout } from '@/components/organisms/PageLayout';
import { PageHeader } from '@/components/organisms/PageHeader';
import { MobileCard } from '@/components/molecules/MobileCard';
import { toast } from 'sonner';
import { cn } from '@/lib/utils';
import type { Project, ProjectStats, ChartViewMode, SelectOption } from './types';
import { STATUS_OPTIONS, SORT_OPTIONS } from './types';
import { DateRangeSelector } from '@/components/molecules/DateRangeSelector';
import { format, startOfMonth, endOfMonth } from 'date-fns';
import {
getProjectList,
getProjectStats,
getPartnerOptions,
getSiteOptions,
getContractManagerOptions,
getConstructionPMOptions,
} from './actions';
import ProjectGanttChart from './ProjectGanttChart';
// 다중 선택 셀렉트 컴포넌트
function MultiSelectFilter({
label,
options,
value,
onChange,
}: {
label: string;
options: SelectOption[];
value: string[];
onChange: (value: string[]) => void;
}) {
const [open, setOpen] = useState(false);
const handleToggle = (optionValue: string) => {
if (optionValue === 'all') {
onChange(['all']);
} else {
const newValue = value.includes(optionValue)
? value.filter((v) => v !== optionValue && v !== 'all')
: [...value.filter((v) => v !== 'all'), optionValue];
onChange(newValue.length === 0 ? ['all'] : newValue);
}
};
const displayValue = value.includes('all') || value.length === 0
? '전체'
: value.length === 1
? options.find((o) => o.value === value[0])?.label || value[0]
: `${value.length}개 선택`;
return (
<div className="relative">
<Button
variant="outline"
className="w-[140px] justify-between text-left font-normal"
onClick={() => setOpen(!open)}
>
<span className="truncate">{displayValue}</span>
</Button>
{open && (
<>
<div className="fixed inset-0 z-40" onClick={() => setOpen(false)} />
<div className="absolute top-full left-0 z-50 mt-1 w-[200px] rounded-md border bg-popover p-1 shadow-md">
<div
className="flex items-center gap-2 p-2 hover:bg-muted rounded cursor-pointer"
onClick={() => handleToggle('all')}
>
<Checkbox checked={value.includes('all') || value.length === 0} />
<span className="text-sm"></span>
</div>
{options.map((option) => (
<div
key={option.value}
className="flex items-center gap-2 p-2 hover:bg-muted rounded cursor-pointer"
onClick={() => handleToggle(option.value)}
>
<Checkbox checked={value.includes(option.value)} />
<span className="text-sm">{option.label}</span>
</div>
))}
</div>
</>
)}
</div>
);
}
interface ProjectListClientProps {
initialData?: Project[];
initialStats?: ProjectStats;
}
export default function ProjectListClient({ initialData = [], initialStats }: ProjectListClientProps) {
const router = useRouter();
// 상태
const [projects, setProjects] = useState<Project[]>(initialData);
const [stats, setStats] = useState<ProjectStats>(
initialStats ?? { total: 0, inProgress: 0, completed: 0 }
);
const [isLoading, setIsLoading] = useState(false);
// 날짜 범위 (기간 선택)
const [filterStartDate, setFilterStartDate] = useState(() => format(startOfMonth(new Date()), 'yyyy-MM-dd'));
const [filterEndDate, setFilterEndDate] = useState(() => format(endOfMonth(new Date()), 'yyyy-MM-dd'));
// 간트차트 상태
const [chartViewMode, setChartViewMode] = useState<ChartViewMode>('day');
// TODO: 실제 API 연동 시 new Date()로 변경 (현재 목업 데이터가 2025년이라 임시 설정)
const [chartDate, setChartDate] = useState(new Date(2025, 0, 15));
const [chartPartnerFilter, setChartPartnerFilter] = useState<string[]>(['all']);
const [chartSiteFilter, setChartSiteFilter] = useState<string[]>(['all']);
// 테이블 필터
const [partnerFilter, setPartnerFilter] = useState<string[]>(['all']);
const [contractManagerFilter, setContractManagerFilter] = useState<string[]>(['all']);
const [pmFilter, setPmFilter] = useState<string[]>(['all']);
const [statusFilter, setStatusFilter] = useState('all');
const [sortBy, setSortBy] = useState<'latest' | 'progress' | 'register' | 'completion'>('latest');
// 필터 옵션들
const [partnerOptions, setPartnerOptions] = useState<SelectOption[]>([]);
const [siteOptions, setSiteOptions] = useState<SelectOption[]>([]);
const [contractManagerOptions, setContractManagerOptions] = useState<SelectOption[]>([]);
const [pmOptions, setPmOptions] = useState<SelectOption[]>([]);
// 테이블 상태
const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set());
const [currentPage, setCurrentPage] = useState(1);
const itemsPerPage = 20;
// 데이터 로드
const loadData = useCallback(async () => {
setIsLoading(true);
try {
const [listResult, statsResult, partners, sites, managers, pms] = await Promise.all([
getProjectList({
partners: partnerFilter.includes('all') ? undefined : partnerFilter,
contractManagers: contractManagerFilter.includes('all') ? undefined : contractManagerFilter,
constructionPMs: pmFilter.includes('all') ? undefined : pmFilter,
status: statusFilter === 'all' ? undefined : statusFilter,
sortBy,
size: 1000,
}),
getProjectStats(),
getPartnerOptions(),
getSiteOptions(),
getContractManagerOptions(),
getConstructionPMOptions(),
]);
if (listResult.success && listResult.data) {
setProjects(listResult.data.items);
}
if (statsResult.success && statsResult.data) {
setStats(statsResult.data);
}
if (partners.success && partners.data) {
setPartnerOptions(partners.data);
}
if (sites.success && sites.data) {
setSiteOptions(sites.data);
}
if (managers.success && managers.data) {
setContractManagerOptions(managers.data);
}
if (pms.success && pms.data) {
setPmOptions(pms.data);
}
} catch {
toast.error('데이터 로드에 실패했습니다.');
} finally {
setIsLoading(false);
}
}, [partnerFilter, contractManagerFilter, pmFilter, statusFilter, sortBy]);
useEffect(() => {
loadData();
}, [loadData]);
// 간트차트용 필터링된 프로젝트
const chartProjects = useMemo(() => {
return projects.filter((project) => {
if (!chartPartnerFilter.includes('all') && !chartPartnerFilter.includes(project.partnerName)) {
return false;
}
if (!chartSiteFilter.includes('all') && !chartSiteFilter.includes(project.siteName)) {
return false;
}
return true;
});
}, [projects, chartPartnerFilter, chartSiteFilter]);
// 페이지네이션
const totalPages = Math.ceil(projects.length / itemsPerPage);
const paginatedData = useMemo(() => {
const start = (currentPage - 1) * itemsPerPage;
return projects.slice(start, start + itemsPerPage);
}, [projects, currentPage, itemsPerPage]);
const startIndex = (currentPage - 1) * itemsPerPage;
// 핸들러
const handleToggleSelection = useCallback((id: string) => {
setSelectedItems((prev) => {
const newSet = new Set(prev);
if (newSet.has(id)) {
newSet.delete(id);
} else {
newSet.add(id);
}
return newSet;
});
}, []);
const handleToggleSelectAll = useCallback(() => {
if (selectedItems.size === paginatedData.length) {
setSelectedItems(new Set());
} else {
setSelectedItems(new Set(paginatedData.map((p) => p.id)));
}
}, [selectedItems.size, paginatedData]);
const handleRowClick = useCallback(
(project: Project) => {
router.push(`/ko/construction/project/management/${project.id}`);
},
[router]
);
const handleEdit = useCallback(
(e: React.MouseEvent, projectId: string) => {
e.stopPropagation();
router.push(`/ko/construction/project/management/${projectId}/edit`);
},
[router]
);
const handleGanttProjectClick = useCallback(
(project: Project) => {
router.push(`/ko/construction/project/management/${project.id}`);
},
[router]
);
// 금액 포맷
const formatAmount = (amount: number) => {
return amount.toLocaleString() + '원';
};
// 날짜 포맷
const formatDate = (dateStr: string) => {
return dateStr.replace(/-/g, '.');
};
// 상태 뱃지
const getStatusBadge = (status: string, hasUrgentIssue: boolean) => {
if (hasUrgentIssue) {
return <Badge variant="destructive"></Badge>;
}
switch (status) {
case 'completed':
return <Badge variant="secondary"></Badge>;
case 'in_progress':
return <Badge variant="default"></Badge>;
default:
return <Badge variant="outline">{status}</Badge>;
}
};
const allSelected = selectedItems.size === paginatedData.length && paginatedData.length > 0;
return (
<PageLayout>
{/* 페이지 헤더 */}
<PageHeader
title="프로젝트 관리"
description="계약 완료 시 자동 등록된 프로젝트를 관리합니다"
icon={FolderKanban}
/>
{/* 기간 선택 (달력 + 프리셋 버튼) */}
<DateRangeSelector
startDate={filterStartDate}
endDate={filterEndDate}
onStartDateChange={setFilterStartDate}
onEndDateChange={setFilterEndDate}
/>
{/* 상태 카드 */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<Card>
<CardContent className="p-4">
<div className="flex items-center gap-3">
<div className="p-2 bg-blue-100 rounded-lg">
<ClipboardList className="h-5 w-5 text-blue-600" />
</div>
<div>
<p className="text-sm text-muted-foreground"> </p>
<p className="text-2xl font-bold">{stats.total}</p>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="flex items-center gap-3">
<div className="p-2 bg-green-100 rounded-lg">
<PlayCircle className="h-5 w-5 text-green-600" />
</div>
<div>
<p className="text-sm text-muted-foreground"> </p>
<p className="text-2xl font-bold">{stats.inProgress}</p>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="flex items-center gap-3">
<div className="p-2 bg-gray-100 rounded-lg">
<CheckCircle2 className="h-5 w-5 text-gray-600" />
</div>
<div>
<p className="text-sm text-muted-foreground"> </p>
<p className="text-2xl font-bold">{stats.completed}</p>
</div>
</div>
</CardContent>
</Card>
</div>
{/* 프로젝트 일정 간트차트 */}
<Card>
<CardContent className="p-4">
<div className="flex flex-col gap-4">
{/* 간트차트 상단 컨트롤 */}
<div className="flex flex-wrap items-center justify-between gap-4">
<div className="flex items-center gap-2">
<span className="text-sm font-medium"> </span>
</div>
<div className="flex flex-wrap items-center gap-3">
{/* 일/주/월 전환 */}
<div className="flex border rounded-md">
<Button
variant={chartViewMode === 'day' ? 'default' : 'ghost'}
size="sm"
className="rounded-r-none border-r-0"
onClick={() => setChartViewMode('day')}
>
</Button>
<Button
variant={chartViewMode === 'week' ? 'default' : 'ghost'}
size="sm"
className="rounded-none border-r-0"
onClick={() => setChartViewMode('week')}
>
</Button>
<Button
variant={chartViewMode === 'month' ? 'default' : 'ghost'}
size="sm"
className="rounded-l-none"
onClick={() => setChartViewMode('month')}
>
</Button>
</div>
{/* 거래처 필터 */}
<MultiSelectFilter
label="거래처"
options={partnerOptions}
value={chartPartnerFilter}
onChange={setChartPartnerFilter}
/>
{/* 현장 필터 */}
<MultiSelectFilter
label="현장"
options={siteOptions}
value={chartSiteFilter}
onChange={setChartSiteFilter}
/>
</div>
</div>
{/* 간트차트 */}
<ProjectGanttChart
projects={chartProjects}
viewMode={chartViewMode}
currentDate={chartDate}
onProjectClick={handleGanttProjectClick}
onDateChange={setChartDate}
/>
</div>
</CardContent>
</Card>
{/* 테이블 영역 */}
<Card>
<CardContent className="pt-6">
{/* 테이블 헤더 (필터들) */}
<div className="flex flex-wrap items-center justify-between gap-3 mb-4">
<span className="text-sm text-muted-foreground whitespace-nowrap">
{projects.length}
</span>
<div className="flex flex-wrap items-center gap-3">
{/* 거래처 필터 */}
<MultiSelectFilter
label="거래처"
options={partnerOptions}
value={partnerFilter}
onChange={(v) => {
setPartnerFilter(v);
setCurrentPage(1);
}}
/>
{/* 계약담당자 필터 */}
<MultiSelectFilter
label="계약담당자"
options={contractManagerOptions}
value={contractManagerFilter}
onChange={(v) => {
setContractManagerFilter(v);
setCurrentPage(1);
}}
/>
{/* 공사PM 필터 */}
<MultiSelectFilter
label="공사PM"
options={pmOptions}
value={pmFilter}
onChange={(v) => {
setPmFilter(v);
setCurrentPage(1);
}}
/>
{/* 상태 필터 */}
<Select value={statusFilter} onValueChange={(v) => { setStatusFilter(v); setCurrentPage(1); }}>
<SelectTrigger className="w-[100px]">
<SelectValue placeholder="전체" />
</SelectTrigger>
<SelectContent>
{STATUS_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
{/* 정렬 */}
<Select value={sortBy} onValueChange={(v) => setSortBy(v as typeof sortBy)}>
<SelectTrigger className="w-[100px]">
<SelectValue placeholder="최신순" />
</SelectTrigger>
<SelectContent>
{SORT_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* 데스크톱 테이블 */}
<div className="hidden xl:block rounded-md border overflow-x-auto">
<Table>
<TableHeader>
<TableRow className="h-14">
<TableHead className="w-[50px] text-center">
<Checkbox
checked={allSelected}
onCheckedChange={handleToggleSelectAll}
/>
</TableHead>
<TableHead className="w-[60px] text-center"></TableHead>
<TableHead className="w-[100px]"></TableHead>
<TableHead className="w-[120px]"></TableHead>
<TableHead className="min-w-[150px]"></TableHead>
<TableHead className="w-[100px]"></TableHead>
<TableHead className="w-[100px]">PM</TableHead>
<TableHead className="w-[80px] text-center"> </TableHead>
<TableHead className="w-[120px] text-right"></TableHead>
<TableHead className="w-[80px] text-center"></TableHead>
<TableHead className="w-[120px] text-right"> </TableHead>
<TableHead className="w-[180px] text-center"> </TableHead>
<TableHead className="w-[80px] text-center"></TableHead>
<TableHead className="w-[80px] text-center"></TableHead>
</TableRow>
</TableHeader>
<TableBody className="[&_tr]:h-14 [&_tr]:min-h-[56px] [&_tr]:max-h-[56px]">
{paginatedData.length === 0 ? (
<TableRow>
<TableCell colSpan={14} className="h-24 text-center">
.
</TableCell>
</TableRow>
) : (
paginatedData.map((project, index) => {
const isSelected = selectedItems.has(project.id);
const globalIndex = startIndex + index + 1;
return (
<TableRow
key={project.id}
className="cursor-pointer hover:bg-muted/50"
onClick={() => handleRowClick(project)}
>
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
<Checkbox
checked={isSelected}
onCheckedChange={() => handleToggleSelection(project.id)}
/>
</TableCell>
<TableCell className="text-center text-muted-foreground">{globalIndex}</TableCell>
<TableCell className="font-medium">{project.contractNumber}</TableCell>
<TableCell>{project.partnerName}</TableCell>
<TableCell>{project.siteName}</TableCell>
<TableCell>{project.contractManager}</TableCell>
<TableCell>{project.constructionPM}</TableCell>
<TableCell className="text-center">{project.totalLocations}</TableCell>
<TableCell className="text-right">{formatAmount(project.contractAmount)}</TableCell>
<TableCell className="text-center">{project.progressRate}%</TableCell>
<TableCell className="text-right">{formatAmount(project.accumulatedPayment)}</TableCell>
<TableCell className="text-center">
{formatDate(project.startDate)} ~ {formatDate(project.endDate)}
</TableCell>
<TableCell className="text-center">
{getStatusBadge(project.status, project.hasUrgentIssue)}
</TableCell>
<TableCell className="text-center">
{isSelected && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={(e) => handleEdit(e, project.id)}
>
<Pencil className="h-4 w-4" />
</Button>
)}
</TableCell>
</TableRow>
);
})
)}
</TableBody>
</Table>
</div>
{/* 모바일/태블릿 카드 뷰 */}
<div className="xl:hidden space-y-4 md:grid md:grid-cols-2 md:gap-4 lg:grid-cols-3">
{projects.length === 0 ? (
<div className="text-center py-6 text-muted-foreground border rounded-lg">
.
</div>
) : (
projects.map((project, index) => {
const isSelected = selectedItems.has(project.id);
return (
<MobileCard
key={project.id}
title={project.siteName}
subtitle={project.contractNumber}
badge={project.hasUrgentIssue ? '긴급' : project.status === 'completed' ? '완료' : '진행중'}
badgeVariant={project.hasUrgentIssue ? 'destructive' : project.status === 'completed' ? 'secondary' : 'default'}
isSelected={isSelected}
onToggle={() => handleToggleSelection(project.id)}
onClick={() => handleRowClick(project)}
details={[
{ label: '거래처', value: project.partnerName },
{ label: '공사PM', value: project.constructionPM },
{ label: '진행률', value: `${project.progressRate}%` },
{ label: '계약금액', value: formatAmount(project.contractAmount) },
]}
/>
);
})
)}
</div>
</CardContent>
</Card>
{/* 페이지네이션 */}
{totalPages > 1 && (
<div className="hidden xl:flex items-center justify-between">
<div className="text-sm text-muted-foreground">
{projects.length} {startIndex + 1}-{Math.min(startIndex + itemsPerPage, projects.length)}
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage(currentPage - 1)}
disabled={currentPage === 1}
>
</Button>
<div className="flex items-center gap-1">
{Array.from({ length: totalPages }, (_, i) => i + 1).map((page) => {
if (
page === 1 ||
page === totalPages ||
(page >= currentPage - 2 && page <= currentPage + 2)
) {
return (
<Button
key={page}
variant={page === currentPage ? 'default' : 'outline'}
size="sm"
onClick={() => setCurrentPage(page)}
className="min-w-[36px]"
>
{page}
</Button>
);
} else if (page === currentPage - 3 || page === currentPage + 3) {
return <span key={page} className="px-2">...</span>;
}
return null;
})}
</div>
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage(currentPage + 1)}
disabled={currentPage === totalPages}
>
</Button>
</div>
</div>
)}
</PageLayout>
);
}

View File

@@ -0,0 +1,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>
);
}

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -0,0 +1,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: '오차장' },
];

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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="거래처명, 거래처번호, 대표자 검색"

View File

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

View File

@@ -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="단가번호, 품목명, 카테고리, 거래처 검색"

View File

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

View File

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

View 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: '상태 변경에 실패했습니다.',
};
}
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,3 @@
export { default as ProgressBillingDetailForm } from './ProgressBillingDetailForm';
export * from './types';
export * from './actions';

View File

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

View File

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

View File

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