feat: 생산/품질/자재/출고/주문 관리 페이지 구현
- 생산관리: 대시보드, 작업지시, 작업실적, 작업자화면 - 품질관리: 검사관리 (리스트/등록/상세) - 자재관리: 입고관리, 재고현황 - 출고관리: 출하관리 (리스트/등록/상세/수정) - 주문관리: 수주관리, 생산의뢰 - 기존 컴포넌트 개선: CardTransactionInquiry, VendorDetail, QuoteRegistration - IntegratedListTemplateV2 개선 - 공통 컴포넌트 분석 문서 추가 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
# 전체 페이지 테스트 URL 목록
|
||||
|
||||
> 백엔드 메뉴 연동 전 테스트용 직접 접근 URL (Last Updated: 2025-12-19)
|
||||
> 백엔드 메뉴 연동 전 테스트용 직접 접근 URL (Last Updated: 2025-12-23)
|
||||
|
||||
## 🚀 클릭 가능한 웹 페이지
|
||||
|
||||
@@ -83,9 +83,49 @@ http://localhost:3000/ko/master-data/item-master-data-management
|
||||
| 페이지 | URL | 상태 |
|
||||
|--------|-----|------|
|
||||
| 스크린 생산 | `/ko/production/screen-production` | ✅ |
|
||||
| 작업지시 관리 | `/ko/production/work-orders` | ✅ |
|
||||
| **작업실적 조회** | `/ko/production/work-results` | 🆕 NEW |
|
||||
|
||||
```
|
||||
http://localhost:3000/ko/production/screen-production
|
||||
http://localhost:3000/ko/production/work-orders
|
||||
http://localhost:3000/ko/production/work-results # 🆕 작업실적 조회
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📦 자재관리 (Material)
|
||||
|
||||
| 페이지 | URL | 상태 |
|
||||
|--------|-----|------|
|
||||
| **재고현황** | `/ko/material/stock-status` | 🆕 NEW |
|
||||
|
||||
```
|
||||
http://localhost:3000/ko/material/stock-status # 🆕 재고현황
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔬 품질관리 (Quality)
|
||||
|
||||
| 페이지 | URL | 상태 |
|
||||
|--------|-----|------|
|
||||
| **검사관리** | `/ko/quality/inspections` | 🆕 NEW |
|
||||
|
||||
```
|
||||
http://localhost:3000/ko/quality/inspections # 🆕 검사관리
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📤 출고관리 (Outbound)
|
||||
|
||||
| 페이지 | URL | 상태 |
|
||||
|--------|-----|------|
|
||||
| **출하 목록** | `/ko/outbound/shipments` | 🆕 NEW |
|
||||
|
||||
```
|
||||
http://localhost:3000/ko/outbound/shipments # 🆕 출하관리
|
||||
```
|
||||
|
||||
---
|
||||
@@ -278,6 +318,23 @@ http://localhost:3000/ko/master-data/item-master-data-management
|
||||
### Production
|
||||
```
|
||||
http://localhost:3000/ko/production/screen-production
|
||||
http://localhost:3000/ko/production/work-orders
|
||||
http://localhost:3000/ko/production/work-results # 🆕 작업실적 조회
|
||||
```
|
||||
|
||||
### Material
|
||||
```
|
||||
http://localhost:3000/ko/material/stock-status # 🆕 재고현황
|
||||
```
|
||||
|
||||
### Quality
|
||||
```
|
||||
http://localhost:3000/ko/quality/inspections # 🆕 검사관리
|
||||
```
|
||||
|
||||
### Outbound
|
||||
```
|
||||
http://localhost:3000/ko/outbound/shipments # 🆕 출하관리
|
||||
```
|
||||
|
||||
### Settings
|
||||
@@ -359,6 +416,17 @@ http://localhost:3000/ko/customer-center/inquiries # 1:1 문의
|
||||
|
||||
// Production
|
||||
'/production/screen-production'
|
||||
'/production/work-orders' // 작업지시 관리
|
||||
'/production/work-results' // 작업실적 조회 (🆕 NEW)
|
||||
|
||||
// Material (자재관리)
|
||||
'/material/stock-status' // 재고현황 (🆕 NEW)
|
||||
|
||||
// Quality (품질관리)
|
||||
'/quality/inspections' // 검사관리 (🆕 NEW)
|
||||
|
||||
// Outbound (출고관리)
|
||||
'/outbound/shipments' // 출하관리 (🆕 NEW)
|
||||
|
||||
// Settings
|
||||
'/settings/leave-policy'
|
||||
@@ -417,4 +485,4 @@ http://localhost:3000/ko/customer-center/inquiries # 1:1 문의
|
||||
## 작성일
|
||||
|
||||
- 최초 작성: 2025-12-06
|
||||
- 최종 업데이트: 2025-12-19 (하위 페이지 정리, 리스트 페이지만 유지)
|
||||
- 최종 업데이트: 2025-12-23 (출고관리 출하관리 페이지 추가)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# claudedocs 문서 맵
|
||||
|
||||
> 프로젝트 기술 문서 인덱스 (Last Updated: 2025-12-20)
|
||||
> 프로젝트 기술 문서 인덱스 (Last Updated: 2025-12-22)
|
||||
|
||||
## ⭐ 빠른 참조
|
||||
|
||||
@@ -18,10 +18,12 @@ claudedocs/
|
||||
├── auth/ # 🔐 인증 & 토큰 관리
|
||||
├── hr/ # 👥 인사관리 (부서/사원)
|
||||
├── item-master/ # 📦 품목기준관리
|
||||
├── production/ # 🏭 생산관리 (생산현황판/작업지시)
|
||||
├── quality/ # 🔬 품질관리 (검사관리) (NEW)
|
||||
├── sales/ # 💰 판매관리 (견적/거래처)
|
||||
├── accounting/ # 💳 회계관리 (매입/매출/출금)
|
||||
├── board/ # 📝 게시판 관리
|
||||
├── settings/ # ⚙️ 설정 관리 (NEW)
|
||||
├── settings/ # ⚙️ 설정 관리
|
||||
├── dashboard/ # 📊 대시보드 & 사이드바
|
||||
├── api/ # 🔌 API 통합
|
||||
├── guides/ # 📚 범용 가이드
|
||||
@@ -92,6 +94,22 @@ claudedocs/
|
||||
|
||||
---
|
||||
|
||||
## 🏭 production/ - 생산관리 (생산현황판/작업지시)
|
||||
|
||||
| 파일 | 설명 |
|
||||
|------|------|
|
||||
| `[IMPL-2025-12-22] production-dashboard-checklist.md` | 🔴 **NEW** - 생산 현황판 구현 체크리스트 (메인/작업자화면, 8 Phase) |
|
||||
|
||||
---
|
||||
|
||||
## 🔬 quality/ - 품질관리 (검사관리)
|
||||
|
||||
| 파일 | 설명 |
|
||||
|------|------|
|
||||
| `[IMPL-2025-12-23] inspection-management-checklist.md` | 🔴 **NEW** - 검사관리 구현 체크리스트 (리스트/등록/상세/수정, 7 Phase) |
|
||||
|
||||
---
|
||||
|
||||
## 💰 sales/ - 판매관리 (견적/거래처/단가)
|
||||
|
||||
| 파일 | 설명 |
|
||||
@@ -134,6 +152,7 @@ claudedocs/
|
||||
|
||||
| 파일 | 설명 |
|
||||
|------|------|
|
||||
| `[ANALYSIS-2025-12-23] common-component-extraction-candidates.md` | 🔴 **NEW** - 공통 컴포넌트 추출 후보 분석 (다이얼로그 102개 중복, ~2,370줄 절감 예상) |
|
||||
| `[PLAN-2025-12-19] project-health-improvement.md` | ✅ **Phase 1 완료** - 프로젝트 헬스 개선 계획서 (타입에러 0개, API키 보안, SSR 수정) |
|
||||
| `[PLAN-2025-12-19] page-layout-standardization.md` | 🔴 **NEW** - 페이지 레이아웃 표준화 계획 |
|
||||
| `[GUIDE-2025-12-16] options-vs-flattened-data.md` | options vs 평탄화 데이터 패턴 (API 응답 매핑 시 options 직접 파싱 금지) |
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
# 공통 컴포넌트 추출 후보 분석
|
||||
|
||||
> 프로젝트 전반의 반복 패턴 분석 및 공통화 후보 목록 (2025-12-23)
|
||||
|
||||
## 현황 요약
|
||||
|
||||
| 구분 | 수치 |
|
||||
|-----|------|
|
||||
| 전체 컴포넌트 파일 | 317개 |
|
||||
| Dialog/AlertDialog 사용 파일 | 102개 |
|
||||
| 공통 StandardDialog 사용 | 1개 (quote-management만) |
|
||||
| 예상 코드 절감 | ~2,370줄 |
|
||||
|
||||
---
|
||||
|
||||
## 기존 공통 컴포넌트 (사용률 저조)
|
||||
|
||||
| 컴포넌트 | 위치 | 사용 현황 |
|
||||
|---------|------|----------|
|
||||
| `StandardDialog` | `molecules/StandardDialog.tsx` | 1곳 사용 |
|
||||
| `ConfirmDialog` | `molecules/StandardDialog.tsx` | 미사용 |
|
||||
| `FormDialog` | `molecules/StandardDialog.tsx` | 미사용 |
|
||||
|
||||
---
|
||||
|
||||
## 공통화 우선순위
|
||||
|
||||
### 🔴 긴급 (높은 중복률)
|
||||
|
||||
| 컴포넌트 | 현재 중복 | 예상 절감 | 설명 |
|
||||
|---------|----------|----------|------|
|
||||
| **DeleteConfirmDialog** | 54+ 파일 | ~810줄 | AlertDialog 기반 삭제 확인 |
|
||||
| **ActionButtons** | 35+ 파일 | ~700줄 | Edit/Delete/Add 버튼 세트 |
|
||||
| **TableActionCell** | 30+ 파일 | ~360줄 | 행 선택 시 액션 버튼 |
|
||||
| **FormDialog** | 20+ 파일 | ~500줄 | Dialog + Form 조합 |
|
||||
|
||||
#### 세부 파일 목록 (DeleteConfirmDialog)
|
||||
```
|
||||
- ItemListClient.tsx
|
||||
- VendorManagement/index.tsx
|
||||
- SalesManagement/index.tsx
|
||||
- AccountManagement/index.tsx
|
||||
- BoardManagement/index.tsx
|
||||
- PurchaseManagement/index.tsx
|
||||
- DepositManagement/index.tsx
|
||||
- WithdrawalManagement/index.tsx
|
||||
- BillManagement/index.tsx
|
||||
- EmployeeManagement/index.tsx
|
||||
- DepartmentManagement/index.tsx
|
||||
- VacationManagement/index.tsx
|
||||
- RankManagement/index.tsx
|
||||
- TitleManagement/index.tsx
|
||||
- PermissionManagement/index.tsx
|
||||
- CardManagement/index.tsx
|
||||
- PopupManagement/PopupList.tsx
|
||||
- ... (54개+)
|
||||
```
|
||||
|
||||
#### 세부 파일 목록 (Dialog + Form 조합)
|
||||
```
|
||||
- RankDialog.tsx
|
||||
- TitleDialog.tsx
|
||||
- PermissionDialog.tsx
|
||||
- DepartmentDialog.tsx
|
||||
- EmployeeDialog.tsx
|
||||
- VacationRegisterDialog.tsx
|
||||
- VacationRequestDialog.tsx
|
||||
- VacationGrantDialog.tsx
|
||||
- VacationAdjustDialog.tsx
|
||||
- VacationTypeSettingsDialog.tsx
|
||||
- UserInviteDialog.tsx
|
||||
- CSVUploadDialog.tsx
|
||||
- SalaryDetailDialog.tsx
|
||||
- AttendanceInfoDialog.tsx
|
||||
- ReasonInfoDialog.tsx
|
||||
- FieldSettingsDialog.tsx
|
||||
- ... (20개+)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 🟡 중간 우선순위
|
||||
|
||||
| 컴포넌트 | 현재 중복 | 설명 |
|
||||
|---------|----------|------|
|
||||
| **TableWrapper** | 40+ 파일 | 컬럼 정의 기반 자동 생성 |
|
||||
| **EmptyStateTemplate** | 12+ 파일 | 빈 상태 통일 |
|
||||
| **StatCard** | 5+ 파일 | 통계 카드 (아이콘+값+라벨) |
|
||||
| **DetailCard** | 20+ 파일 | 상세보기 카드 래퍼 |
|
||||
| **SearchFilterBar** | 40+ 파일 | 검색 + 필터 조합 |
|
||||
|
||||
---
|
||||
|
||||
### 🟢 낮음 (이미 공통화됨, 강화 필요)
|
||||
|
||||
| 컴포넌트 | 상태 | 개선 필요사항 |
|
||||
|---------|------|-------------|
|
||||
| **LoadingSpinner** | ✅ 존재 | 테이블용/페이지용 변형 추가 |
|
||||
| **SearchFilter** | ✅ 존재 | 날짜범위, 다중선택 필터 |
|
||||
| **Pagination** | ✅ 존재 | 현재 잘 작동 중 |
|
||||
| **IntegratedListTemplateV2** | ✅ 존재 | 잘 사용 중 |
|
||||
|
||||
---
|
||||
|
||||
## 패턴별 상세 분석
|
||||
|
||||
### 1. 다이얼로그/모달 패턴
|
||||
|
||||
| 패턴 | 사용 빈도 | 파일 수 | 우선순위 |
|
||||
|------|---------|-------|--------|
|
||||
| 삭제 확인 AlertDialog | 매우 높음 | 54+ | 🔴 높음 |
|
||||
| 정보 입력 Dialog | 높음 | 20+ | 🔴 높음 |
|
||||
| 상세 조회 Modal | 높음 | 15+ | 🟡 중간 |
|
||||
| CSV/파일 업로드 Dialog | 중간 | 5+ | 🟡 중간 |
|
||||
|
||||
### 2. 테이블 관련 패턴
|
||||
|
||||
| 패턴 | 사용 빈도 | 파일 수 | 우선순위 |
|
||||
|------|---------|-------|--------|
|
||||
| 테이블 액션 버튼 | 매우 높음 | 35+ | 🔴 높음 |
|
||||
| 체크박스 행 선택 | 매우 높음 | 40+ | 🔴 높음 |
|
||||
| 페이지네이션 | 높음 | 39+ | ✅ 공통화됨 |
|
||||
| 테이블 헤더/행 구조 | 높음 | 40+ | 🟡 중간 |
|
||||
|
||||
### 3. 폼 관련 패턴
|
||||
|
||||
| 패턴 | 사용 빈도 | 파일 수 | 우선순위 |
|
||||
|------|---------|-------|--------|
|
||||
| 검색 폼 | 높음 | 40+ | ✅ 공통화됨 |
|
||||
| 동적 폼 필드 | 중간 | 8+ | ✅ 공통화됨 |
|
||||
| 폼 상태 관리 | 중간 | 15+ | 🟡 중간 |
|
||||
| 폼 유효성 검사 | 중간 | 10+ | 🟡 중간 |
|
||||
|
||||
### 4. 상태 표시 패턴
|
||||
|
||||
| 패턴 | 사용 빈도 | 파일 수 | 우선순위 |
|
||||
|------|---------|-------|--------|
|
||||
| 로딩 스피너 | 높음 | 5 | ✅ 공통화됨 |
|
||||
| 빈 상태 | 높음 | 12+ | 🟡 중간 |
|
||||
| 에러 메시지 | 중간 | 10+ | 🟡 중간 |
|
||||
| 배지/상태 표시 | 높음 | 30+ | 🟡 중간 |
|
||||
|
||||
### 5. 액션 버튼 그룹 패턴
|
||||
|
||||
| 패턴 | 사용 빈도 | 파일 수 | 우선순위 |
|
||||
|------|---------|-------|--------|
|
||||
| CRUD 버튼 세트 | 매우 높음 | 35+ | 🔴 높음 |
|
||||
| Form 액션 버튼 | 높음 | 20+ | 🔴 높음 |
|
||||
| 행 액션 버튼 | 높음 | 30+ | 🔴 높음 |
|
||||
|
||||
---
|
||||
|
||||
## 추천 구현 순서
|
||||
|
||||
### Phase 1: 다이얼로그 공통화
|
||||
1. `DeleteConfirmDialog` - 삭제 확인용 (54+ 파일 영향)
|
||||
2. 기존 `ConfirmDialog` 활용 또는 강화
|
||||
|
||||
### Phase 2: 액션 버튼 공통화
|
||||
3. `ActionButtonGroup` - CRUD 버튼 세트
|
||||
4. `TableActionCell` - 테이블 행 액션 버튼
|
||||
|
||||
### Phase 3: 폼 다이얼로그 공통화
|
||||
5. 기존 `FormDialog` 활용 확대
|
||||
6. 도메인별 Dialog들을 FormDialog 기반으로 리팩토링
|
||||
|
||||
### Phase 4: 기타
|
||||
7. `EmptyStateTemplate` 통일
|
||||
8. `StatCard` 통합
|
||||
|
||||
---
|
||||
|
||||
## 기대 효과
|
||||
|
||||
| 항목 | 효과 |
|
||||
|-----|------|
|
||||
| 코드 절감 | ~2,370줄 (전체 대비 5-7%) |
|
||||
| 유지보수성 | 버튼 스타일/동작 통일, 버그 감소 |
|
||||
| 개발 속도 | 새 페이지 작성 시 +30% 빠름 |
|
||||
| UI 일관성 | 전체 앱에서 동일한 UX |
|
||||
|
||||
---
|
||||
|
||||
## 작업 시점 권장
|
||||
|
||||
> ⚠️ **권장**: 프로젝트 기능 구현이 어느 정도 마무리된 시점에 진행
|
||||
> - 현재 새 페이지가 계속 추가되는 중
|
||||
> - 리팩토링 후 다시 중복 코드가 생길 수 있음
|
||||
> - MVP 완료 후 일괄 작업이 효율적
|
||||
|
||||
---
|
||||
|
||||
## 참고: 공통 컴포넌트 경로
|
||||
|
||||
```
|
||||
src/components/
|
||||
├── ui/ # 기본 UI 컴포넌트 (shadcn)
|
||||
│ ├── dialog.tsx
|
||||
│ ├── alert-dialog.tsx
|
||||
│ ├── button.tsx
|
||||
│ └── ...
|
||||
├── molecules/ # 조합 컴포넌트
|
||||
│ └── StandardDialog.tsx # ⭐ 기존 공통 다이얼로그 (미사용)
|
||||
├── templates/ # 페이지 템플릿
|
||||
│ └── IntegratedListTemplateV2.tsx
|
||||
└── [domain]/ # 도메인별 컴포넌트
|
||||
└── *Dialog.tsx # 개별 다이얼로그들 (중복)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 변경 이력
|
||||
|
||||
| 날짜 | 변경 내용 |
|
||||
|-----|----------|
|
||||
| 2025-12-23 | 최초 작성 - 공통화 후보 분석 |
|
||||
97
claudedocs/material/[IMPL-2025-12-23] stock-status.md
Normal file
97
claudedocs/material/[IMPL-2025-12-23] stock-status.md
Normal file
@@ -0,0 +1,97 @@
|
||||
# 자재관리 - 재고현황 페이지 구현
|
||||
|
||||
**경로**: `/material/stock-status`
|
||||
**작업일**: 2025-12-23
|
||||
|
||||
---
|
||||
|
||||
## 📋 구현 체크리스트
|
||||
|
||||
### Phase 1: 기본 구조 설정
|
||||
- [x] 폴더 구조 생성 (`src/components/material/StockStatus/`)
|
||||
- [x] 페이지 라우트 생성 (`src/app/[locale]/(protected)/material/stock-status/`)
|
||||
- [x] types.ts 작성
|
||||
- [x] mockData.ts 작성
|
||||
|
||||
### Phase 2: 리스트 페이지 구현
|
||||
- [x] 통계 카드 4개 (전체 품목, 정상 재고, 재고 부족, 재고 없음)
|
||||
- [x] 필터 탭 (전체, 원자재, 절곡부품, 구매부품, 부자재, 소모품)
|
||||
- [x] 검색 기능 (품목코드, 품목명)
|
||||
- [x] 테이블 구현 (체크박스, 품목코드, 품목명, 품목유형, 단위, 재고량, 안전재고, LOT, 상태, 위치)
|
||||
- [x] 품목유형 뱃지 (구매부품, 부자재, 원자재, 소모품)
|
||||
- [x] 엑셀 다운로드 버튼
|
||||
- [x] 하단 요약 (총 XX종 / 재고부족 X종)
|
||||
|
||||
### Phase 3: 상세 페이지 구현
|
||||
- [x] 상세 페이지 라우트 (`/material/stock-status/[id]`)
|
||||
- [x] 기본 정보 섹션 (품목코드, 품목명, 품목유형, 카테고리, 규격, 단위)
|
||||
- [x] 재고 현황 섹션 (현재 재고량, 안전 재고, 재고 위치, LOT 개수, 최근 입고일, 재고 상태)
|
||||
- [x] LOT별 상세 재고 테이블 (FIFO, LOT번호, 입고일, 경과일, 공급업체, 발주번호, 수량, 위치, 상태)
|
||||
- [x] FIFO 권장 메시지 표시
|
||||
- [x] 목록 버튼
|
||||
|
||||
### Phase 4: 마무리
|
||||
- [x] Mock 데이터 작성
|
||||
- [x] 빌드 테스트
|
||||
- [x] 테스트 URL 문서 업데이트
|
||||
|
||||
---
|
||||
|
||||
## 📊 스크린샷 분석
|
||||
|
||||
### 리스트 페이지 구조
|
||||
|
||||
**통계 카드:**
|
||||
| 카드 | 값 | 아이콘 |
|
||||
|------|-----|--------|
|
||||
| 전체 품목 | 134종 | 기본 |
|
||||
| 정상 재고 | 133종 | ✓ 체크 |
|
||||
| 재고 부족 | 1종 | ⏱ 시계 |
|
||||
| 재고 없음 | 0종 | 기본 |
|
||||
|
||||
**필터 탭:**
|
||||
- 전체 134, 원자재 4, 절곡부품 41, 구매부품 80, 부자재 7, 소모품 2
|
||||
|
||||
**테이블 컬럼:**
|
||||
| 컬럼 | 설명 |
|
||||
|------|------|
|
||||
| 체크박스 | row 선택 |
|
||||
| 품목코드 | SQP-50-40, ANG-75-40 등 |
|
||||
| 품목명 | 각파이프 50×50 L:4000 등 |
|
||||
| 품목유형 | 구매부품/부자재/원자재/소모품 (뱃지) |
|
||||
| 단위 | EA, M, m² |
|
||||
| 재고량 | 숫자 |
|
||||
| 안전재고 | 숫자 |
|
||||
| LOT | X개 + 경과일 (예: 2개 8일 경과) |
|
||||
| 상태 | 정상 |
|
||||
| 위치 | I-05, A-04 등 |
|
||||
|
||||
### 상세 페이지 구조
|
||||
|
||||
**헤더:** 재고 상세 [품목코드] [상태뱃지] + 목록 버튼
|
||||
|
||||
**기본 정보:**
|
||||
- 품목코드, 품목명, 품목유형
|
||||
- 카테고리, 규격, 단위
|
||||
|
||||
**재고 현황:**
|
||||
- 현재 재고량 (큰 숫자, 예: 120 EA)
|
||||
- 안전 재고 (예: 30 EA)
|
||||
- 재고 위치 (예: I-05)
|
||||
- LOT 개수 (예: 4개)
|
||||
- 최근 입고일 (예: 2025-12-13)
|
||||
- 재고 상태 (정상 뱃지)
|
||||
|
||||
**LOT별 상세 재고:**
|
||||
- 토글: FIFO 순서 / 오래된 LOT부터 사용 권장
|
||||
- 테이블: FIFO(번호), LOT번호, 입고일, 경과일, 공급업체, 발주번호, 수량, 위치, 상태
|
||||
- 합계 행
|
||||
- FIFO 권장 메시지: ⓘ FIFO 권장: LOT XXXXXX-XX가 XX일 경과되었습니다. 우선 사용을 권장합니다.
|
||||
|
||||
---
|
||||
|
||||
## 🔧 기술 스택
|
||||
- IntegratedListTemplateV2 (리스트)
|
||||
- PageLayout (상세)
|
||||
- Radix UI (뱃지, 테이블)
|
||||
- Mock 데이터 (API 연동 TODO)
|
||||
@@ -0,0 +1,391 @@
|
||||
# [IMPL-2025-12-22] 생산 현황판 구현 계획서
|
||||
|
||||
## 개요
|
||||
생산관리 하위 **생산 현황판** 및 **작업자 화면** 기능 구현
|
||||
- 생산 현황판: `/production/dashboard`
|
||||
- 작업자 화면: `/production/worker-screen` (별도 메뉴)
|
||||
|
||||
---
|
||||
|
||||
## 1. 페이지 구조
|
||||
|
||||
### 1.1 생산 현황판 (메인)
|
||||
**경로**: `/ko/production/dashboard`
|
||||
|
||||
| 섹션 | 설명 |
|
||||
|------|------|
|
||||
| 상단 탭 | 전체, 스크린공장, 슬랫공장, 절곡공장 |
|
||||
| 통계 카드 | 전체작업, 작업대기, 작업중, 작업완료, 긴급, 지연 (6개) |
|
||||
| 3컬럼 레이아웃 | 긴급작업 / 지연작업 / 작업자별 현황 |
|
||||
| 우측 상단 버튼 | 작업자 화면, 작업지시 목록 |
|
||||
|
||||
**긴급작업/지연작업 카드 클릭**
|
||||
- → 작업지시 관리 상세 화면 이동 (TODO: 페이지 생성 후 연결)
|
||||
|
||||
**작업지시 목록 버튼**
|
||||
- → 작업지시 관리 리스트 이동 (TODO: 페이지 생성 후 연결)
|
||||
|
||||
### 1.2 작업자 화면 (별도 페이지)
|
||||
**경로**: `/ko/production/worker-screen` (생산 현황판 하위가 아닌 별도 메뉴)
|
||||
|
||||
| 섹션 | 설명 |
|
||||
|------|------|
|
||||
| 상단 통계 | 할당, 작업중, 완료, 긴급 (4개) |
|
||||
| 내 작업 목록 | 카드 리스트 형태 (우선순위순 정렬 옵션) |
|
||||
| 각 카드 | 제품명, EA수량, 납기, 순위 배지, 상태 배지 |
|
||||
| 카드 버튼 | 전량완료, 공정상세, 자재투입, 작업일지, 이슈보고 |
|
||||
|
||||
**참고**: 생산 현황판 복귀 버튼 불필요 (사이드바 메뉴로 이동)
|
||||
|
||||
---
|
||||
|
||||
## 2. 기능 상세
|
||||
|
||||
### 2.1 전량완료 버튼 클릭 시
|
||||
|
||||
#### Step 1: 자재 투입 확인 팝업
|
||||
```
|
||||
제목: 자재 투입이 필요합니다!
|
||||
내용:
|
||||
- 작업지시: KD-WO-251216-01
|
||||
- 공정: 스크린
|
||||
- "자재 투입 없이 완료 처리하시겠습니까? (LOT 추적이 불가능해집니다)"
|
||||
버튼: 취소 / 확인
|
||||
```
|
||||
- **디자인 팝업 사용** (AlertDialog 컴포넌트)
|
||||
|
||||
#### Step 2-A: 확인 클릭 시
|
||||
```
|
||||
제목: 작업이 완료되었습니다.
|
||||
내용:
|
||||
- 제품검사(LOT: KD-SA-251222-01)
|
||||
- 제품검사(FQC)가 자동 생성되었습니다.
|
||||
- "[품질관리 > 제품검사]에서 검사를 진행하세요."
|
||||
버튼: 확인
|
||||
```
|
||||
- **디자인 팝업 사용** (AlertDialog 컴포넌트)
|
||||
|
||||
#### Step 3: 동적 뱃지 표시
|
||||
```
|
||||
검은색 라운드 배지 (상단 중앙)
|
||||
"✓ KD-WO-251216-01 완료! (3EA)"
|
||||
```
|
||||
- 3초 후 자동 사라짐 (애니메이션)
|
||||
- 작업 목록에서 해당 지시사항 제거
|
||||
|
||||
#### Step 2-B: 취소 클릭 시
|
||||
- 자재투입 모달 표시 (팝업 닫힘)
|
||||
|
||||
### 2.2 공정상세 버튼 클릭 시
|
||||
|
||||
**탭 활성화 또는 섹션 확장**
|
||||
| 항목 | 설명 |
|
||||
|------|------|
|
||||
| 자재 투입 필요 | 섹션 + "자재 투입하기" 버튼 |
|
||||
| 공정 단계 (5단계) | 0/5 완료 표시 |
|
||||
| 각 단계 | 절곡판/코일 절단, V컷팅, 절곡, 중간검사, 포장 |
|
||||
| 단계 상세 | #1, #2 등 세부 항목 (위치, 규격, LOT 정보) |
|
||||
|
||||
### 2.3 자재투입 버튼 클릭 시
|
||||
|
||||
**자재투입 모달**
|
||||
```
|
||||
제목: 투입자재 등록
|
||||
FIFO 순위: 1 최우선, 2 차선, 3+ 대기
|
||||
테이블:
|
||||
- 자재코드 | 자재명 | 단위 | 현재고 | 선택
|
||||
- "이 공정에 배정된 자재가 없습니다" (데이터 없을 때)
|
||||
버튼: 취소 / 투입 등록
|
||||
```
|
||||
- Dialog 컴포넌트 사용
|
||||
|
||||
### 2.4 작업일지 버튼 클릭 시
|
||||
|
||||
**작업일지 모달** (기안함 스타일 참고)
|
||||
```
|
||||
제목: 작업일지 - 절곡 생산부서 (KD-WO-FLD-251212-01)
|
||||
우측: 인쇄 버튼
|
||||
내용: 작업일지 양식 (테이블 형태)
|
||||
```
|
||||
- Dialog 컴포넌트 사용
|
||||
- 인쇄 기능: `window.print()` 또는 react-to-print
|
||||
|
||||
### 2.5 이슈 보고 버튼 클릭 시
|
||||
|
||||
**이슈 보고 모달**
|
||||
```
|
||||
제목: 이슈 보고
|
||||
내용:
|
||||
- 작업: KD-WO-FLD-251212-01
|
||||
- 현대건설(주)
|
||||
- 이슈 유형: 불량품 발생, 재고 없음, 일정 지연, 설비 문제, 기타 (5개 버튼)
|
||||
- 상세 내용: textarea
|
||||
버튼: 취소 / 보고
|
||||
```
|
||||
|
||||
#### 벨리데이션
|
||||
- 이슈 유형 미선택 시: **디자인 팝업** "이슈 유형을 선택해주세요."
|
||||
- ❌ `alert()` 사용 금지
|
||||
|
||||
#### 보고 완료 시
|
||||
- **디자인 팝업** "이슈가 보고되었습니다. 작업: KD-WO-FLD-251212-01, 유형: [선택값]"
|
||||
- 확인 후 이슈 보고 화면으로 복귀
|
||||
|
||||
---
|
||||
|
||||
## 3. 네비게이션 연결
|
||||
|
||||
### 3.1 긴급작업/지연작업 카드 클릭
|
||||
- → 작업지시 관리 상세 화면 (`/production/work-orders/[id]`)
|
||||
- **TODO**: 작업지시 관리 페이지 생성 후 연결
|
||||
|
||||
### 3.2 작업지시 목록 버튼
|
||||
- → 작업지시 관리 리스트 (`/production/work-orders`)
|
||||
- **TODO**: 작업지시 관리 페이지 생성 후 연결
|
||||
|
||||
### 3.3 작업자 화면 버튼 (생산 현황판)
|
||||
- → 작업자 화면 (`/production/worker-screen`)
|
||||
- 별도 메뉴로 이동 (사이드바에서도 접근 가능)
|
||||
|
||||
---
|
||||
|
||||
## 4. 디자인 팝업 변경 목록
|
||||
|
||||
| 기존 | 변경 | 컴포넌트 |
|
||||
|------|------|----------|
|
||||
| `alert('자재 투입이 필요합니다')` | AlertDialog | confirm |
|
||||
| `alert('작업이 완료되었습니다')` | AlertDialog | info |
|
||||
| `alert('이슈 유형을 선택해주세요')` | AlertDialog | validation |
|
||||
| `alert('이슈가 보고되었습니다')` | AlertDialog | success |
|
||||
|
||||
---
|
||||
|
||||
## 5. 파일 구조
|
||||
|
||||
```
|
||||
src/app/[locale]/(protected)/production/
|
||||
├── dashboard/
|
||||
│ └── page.tsx # 생산 현황판 메인
|
||||
├── worker-screen/
|
||||
│ └── page.tsx # 작업자 화면 (별도 메뉴)
|
||||
|
||||
src/components/production/
|
||||
├── ProductionDashboard/
|
||||
│ ├── index.tsx # 메인 컴포넌트
|
||||
│ ├── types.ts # 타입 정의
|
||||
│ └── mockData.ts # Mock 데이터
|
||||
│
|
||||
├── WorkerScreen/
|
||||
│ ├── index.tsx # 작업자 화면 메인
|
||||
│ ├── types.ts # 타입 정의
|
||||
│ ├── WorkCard.tsx # 작업 카드 컴포넌트
|
||||
│ ├── ProcessDetailSection.tsx # 공정상세 섹션
|
||||
│ ├── MaterialInputModal.tsx # 자재투입 모달
|
||||
│ ├── WorkLogModal.tsx # 작업일지 모달
|
||||
│ ├── IssueReportModal.tsx # 이슈보고 모달
|
||||
│ ├── CompletionConfirmDialog.tsx # 전량완료 확인 다이얼로그
|
||||
│ └── CompletionToast.tsx # 완료 토스트/뱃지
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 구현 체크리스트
|
||||
|
||||
### Phase 1: 기본 구조 (생산 현황판 메인) ✅
|
||||
- [x] 1.1 `/production/dashboard` 라우트 생성
|
||||
- [x] 1.2 ProductionDashboard 컴포넌트 생성
|
||||
- [x] 1.3 상단 탭 구현 (전체/스크린공장/슬랫공장/절곡공장)
|
||||
- [x] 1.4 통계 카드 6개 구현
|
||||
- [x] 1.5 3컬럼 레이아웃 (긴급작업/지연작업/작업자별현황)
|
||||
- [x] 1.6 긴급작업 리스트 컴포넌트
|
||||
- [x] 1.7 지연작업 리스트 컴포넌트
|
||||
- [x] 1.8 작업자별 현황 컴포넌트
|
||||
- [x] 1.9 우측 상단 버튼 (작업자 화면/작업지시 목록)
|
||||
|
||||
### Phase 2: 작업자 화면 (별도 페이지) ✅
|
||||
- [x] 2.1 `/production/worker-screen` 라우트 생성
|
||||
- [x] 2.2 WorkerScreen 컴포넌트 생성
|
||||
- [x] 2.3 상단 통계 카드 4개 (할당/작업중/완료/긴급)
|
||||
- [x] 2.4 내 작업 목록 카드 리스트 (2열 그리드)
|
||||
- [x] 2.5 WorkCard 컴포넌트 (제품명/EA/납기/배지/버튼)
|
||||
|
||||
### Phase 3: 작업자 화면 - 버튼 기능 ✅
|
||||
- [x] 3.1 전량완료 버튼 → CompletionConfirmDialog
|
||||
- [x] 3.2 자재 미투입 확인 다이얼로그 (AlertDialog)
|
||||
- [x] 3.3 완료 성공 다이얼로그 (AlertDialog)
|
||||
- [x] 3.4 완료 뱃지 애니메이션 (CompletionToast)
|
||||
- [x] 3.5 작업 목록에서 완료 항목 제거
|
||||
|
||||
### Phase 4: 공정상세 기능 ✅
|
||||
- [x] 4.1 ProcessDetailSection 컴포넌트
|
||||
- [x] 4.2 공정 단계 표시 (5단계)
|
||||
- [x] 4.3 각 단계 세부 항목 (#1, #2...)
|
||||
- [x] 4.4 자재 투입 필요 섹션
|
||||
|
||||
### Phase 5: 자재투입 기능 ✅
|
||||
- [x] 5.1 MaterialInputModal 컴포넌트
|
||||
- [x] 5.2 FIFO 순위 표시
|
||||
- [x] 5.3 자재 테이블 (BOM 기준)
|
||||
- [x] 5.4 투입 등록 로직
|
||||
|
||||
### Phase 6: 작업일지 기능 ✅
|
||||
- [x] 6.1 WorkLogModal 컴포넌트
|
||||
- [x] 6.2 작업일지 양식 (기안함 참고)
|
||||
- [x] 6.3 인쇄 기능
|
||||
|
||||
### Phase 7: 이슈보고 기능 ✅
|
||||
- [x] 7.1 IssueReportModal 컴포넌트
|
||||
- [x] 7.2 이슈 유형 선택 (5개 버튼)
|
||||
- [x] 7.3 상세 내용 textarea
|
||||
- [x] 7.4 벨리데이션 다이얼로그 (AlertDialog)
|
||||
- [x] 7.5 보고 완료 다이얼로그 (AlertDialog)
|
||||
|
||||
### Phase 8: 네비게이션 연결 (TODO 노티) ✅
|
||||
- [x] 8.1 긴급/지연 작업 클릭 → console.log + TODO 주석
|
||||
- [x] 8.2 작업지시 목록 버튼 → console.log + TODO 주석
|
||||
- [ ] 8.3 추후 작업지시 관리 페이지 생성 시 연결 (대기)
|
||||
|
||||
---
|
||||
|
||||
## 7. 사용 컴포넌트/라이브러리
|
||||
|
||||
| 용도 | 컴포넌트 |
|
||||
|------|----------|
|
||||
| 확인/취소 팝업 | `@/components/ui/alert-dialog` |
|
||||
| 정보 모달 | `@/components/ui/dialog` |
|
||||
| 버튼 | `@/components/ui/button` |
|
||||
| 배지 | `@/components/ui/badge` |
|
||||
| 카드 | `@/components/ui/card` |
|
||||
| 탭 | `@/components/ui/tabs` |
|
||||
| 테이블 | `@/components/ui/table` |
|
||||
| 체크박스 | `@/components/ui/checkbox` |
|
||||
| Textarea | `@/components/ui/textarea` |
|
||||
|
||||
---
|
||||
|
||||
## 8. Mock 데이터 구조
|
||||
|
||||
### 작업 지시 (WorkOrder)
|
||||
```typescript
|
||||
interface WorkOrder {
|
||||
id: string;
|
||||
orderNo: string; // KD-WO-251216-01
|
||||
productName: string; // 스크린 서터 (표준형) - 추가
|
||||
process: string; // 스크린, 슬랫, 절곡
|
||||
client: string; // 삼성물산(주)
|
||||
projectName: string; // 강남 타워 신축현장
|
||||
assignees: string[]; // 담당자 배열
|
||||
quantity: number; // EA 수량
|
||||
dueDate: string; // 납기
|
||||
priority: number; // 순위 (1~5)
|
||||
status: 'waiting' | 'inProgress' | 'completed';
|
||||
isUrgent: boolean;
|
||||
isDelayed: boolean;
|
||||
instruction?: string; // 지시사항
|
||||
createdAt: string;
|
||||
}
|
||||
```
|
||||
|
||||
### 작업자 현황 (WorkerStatus)
|
||||
```typescript
|
||||
interface WorkerStatus {
|
||||
id: string;
|
||||
name: string;
|
||||
inProgress: number; // 작업중 건수
|
||||
completed: number; // 완료 건수
|
||||
assigned: number; // 배정 건수
|
||||
}
|
||||
```
|
||||
|
||||
### 공정 단계 (ProcessStep)
|
||||
```typescript
|
||||
interface ProcessStep {
|
||||
id: string;
|
||||
stepNo: number; // 1~5
|
||||
name: string; // 절곡판/코일 절단, V컷팅...
|
||||
isInspection?: boolean; // 검사 단계 여부
|
||||
completed: number;
|
||||
total: number;
|
||||
items: ProcessStepItem[];
|
||||
}
|
||||
|
||||
interface ProcessStepItem {
|
||||
id: string;
|
||||
itemNo: string; // #1, #2
|
||||
location: string; // 1층 1호-A
|
||||
isPriority: boolean; // 선행 생산
|
||||
spec: string; // W2500 × H3000
|
||||
material: string; // 자재: 절곡판
|
||||
lot: string; // LOT-절곡-2025-001
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. 확정 사항
|
||||
|
||||
### 확인 완료
|
||||
1. ✅ 모든 alert() → AlertDialog 컴포넌트 사용
|
||||
2. ✅ 작업자 화면은 별도 메뉴 (`/production/worker-screen`)
|
||||
3. ✅ 생산 현황판 복귀 버튼 불필요 (사이드바 메뉴로 이동)
|
||||
4. ✅ 긴급/지연 작업 클릭 → 작업지시 상세로 이동 (페이지 생성 후 연결)
|
||||
5. ✅ 작업지시 목록 버튼 → 작업지시 리스트로 이동 (페이지 생성 후 연결)
|
||||
6. ✅ 작업지시 관리 페이지 → 생산 현황판 완료 후 별도 진행 (스샷/설명 별도 제공 예정)
|
||||
7. ✅ 공정상세 버튼 → 카드 내 토글 확장 방식 (스크린샷 기준)
|
||||
8. ✅ 완료 뱃지 → 상단 중앙 검은색 뱃지, 3초 후 fade out
|
||||
|
||||
---
|
||||
|
||||
## 10. 다음 단계
|
||||
|
||||
사용자 확정 후:
|
||||
1. Phase 1부터 순차적으로 구현
|
||||
2. 각 Phase 완료 시 체크리스트 업데이트
|
||||
3. 테스트 URL 문서 업데이트
|
||||
|
||||
---
|
||||
|
||||
**작성일**: 2025-12-22
|
||||
**작성자**: Claude Code
|
||||
**상태**: ✅ 구현 완료
|
||||
|
||||
---
|
||||
|
||||
## 11. 구현 결과
|
||||
|
||||
### 생성된 파일
|
||||
```
|
||||
src/app/[locale]/(protected)/production/
|
||||
├── dashboard/page.tsx ✅ 생산 현황판 페이지
|
||||
└── worker-screen/page.tsx ✅ 작업자 화면 페이지
|
||||
|
||||
src/components/production/
|
||||
├── ProductionDashboard/
|
||||
│ ├── index.tsx ✅ 메인 컴포넌트
|
||||
│ ├── types.ts ✅ 타입 정의
|
||||
│ └── mockData.ts ✅ Mock 데이터
|
||||
│
|
||||
└── WorkerScreen/
|
||||
├── index.tsx ✅ 작업자 화면 메인
|
||||
├── types.ts ✅ 타입 정의
|
||||
├── WorkCard.tsx ✅ 작업 카드 컴포넌트
|
||||
├── ProcessDetailSection.tsx ✅ 공정상세 섹션
|
||||
├── MaterialInputModal.tsx ✅ 자재투입 모달
|
||||
├── WorkLogModal.tsx ✅ 작업일지 모달
|
||||
├── IssueReportModal.tsx ✅ 이슈보고 모달
|
||||
├── CompletionConfirmDialog.tsx ✅ 전량완료 확인 다이얼로그
|
||||
└── CompletionToast.tsx ✅ 완료 토스트
|
||||
|
||||
src/components/ui/
|
||||
└── collapsible.tsx ✅ Collapsible 컴포넌트 추가
|
||||
```
|
||||
|
||||
### 테스트 URL
|
||||
- 생산 현황판: http://localhost:3000/ko/production/dashboard
|
||||
- 작업자 화면: http://localhost:3000/ko/production/worker-screen
|
||||
|
||||
### 남은 작업
|
||||
- [ ] **작업일지 모달 개선** - 기안함 상세 화면 스타일로 변경
|
||||
- 참고: `src/components/approval/DocumentDetail/` 컴포넌트 활용
|
||||
- 수정: `src/components/production/WorkerScreen/WorkLogModal.tsx`
|
||||
- [ ] 작업지시 관리 페이지 생성 후 네비게이션 연결
|
||||
@@ -0,0 +1,97 @@
|
||||
# [NEXT-2025-12-22] 생산 현황판 세션 컨텍스트
|
||||
|
||||
## 세션 요약 (2025-12-22)
|
||||
|
||||
### 완료된 작업 ✅
|
||||
- [x] Phase 1: 생산 현황판 메인 페이지 구현
|
||||
- [x] Phase 2: 작업자 화면 구현 (별도 페이지)
|
||||
- [x] Phase 3: 전량완료 기능 (확인/완료 팝업, 뱃지)
|
||||
- [x] Phase 4: 공정상세 섹션 구현 (카드 내 토글)
|
||||
- [x] Phase 5: 자재투입 모달 구현
|
||||
- [x] Phase 6: 작업일지 모달 구현 (⚠️ 개선 필요)
|
||||
- [x] Phase 7: 이슈보고 모달 구현
|
||||
- [x] Phase 8: 네비게이션 연결 (TODO 주석 처리)
|
||||
|
||||
### 다음 세션 TODO ⚠️
|
||||
|
||||
#### 1. 작업일지 모달 개선 (우선)
|
||||
**현재**: 단순 테이블 형태로 구현됨
|
||||
**요청**: 기안함 상세 화면 스타일 (완성된 문서 형태)로 개선
|
||||
|
||||
**참고 컴포넌트**:
|
||||
```
|
||||
src/components/approval/DocumentDetail/
|
||||
├── ProposalDocument.tsx ← 기품의서 양식
|
||||
├── ExpenseReportDocument.tsx ← 지출보고서 양식
|
||||
└── ExpenseEstimateDocument.tsx ← 지출품의서 양식
|
||||
```
|
||||
|
||||
**수정 대상**:
|
||||
```
|
||||
src/components/production/WorkerScreen/WorkLogModal.tsx
|
||||
```
|
||||
|
||||
**작업 내용**:
|
||||
- DocumentDetail 컴포넌트 스타일 참고
|
||||
- 완성된 문서 형태로 작업일지 양식 재구현
|
||||
- 인쇄 친화적 레이아웃 적용
|
||||
|
||||
#### 2. 작업지시 관리 페이지 (대기)
|
||||
- 생산 현황판에서 네비게이션 연결 대기
|
||||
- 스크린샷/설명 별도 제공 예정
|
||||
|
||||
---
|
||||
|
||||
### 생성된 파일 목록
|
||||
|
||||
```
|
||||
src/app/[locale]/(protected)/production/
|
||||
├── dashboard/page.tsx ✅
|
||||
└── worker-screen/page.tsx ✅
|
||||
|
||||
src/components/production/
|
||||
├── ProductionDashboard/
|
||||
│ ├── index.tsx ✅
|
||||
│ ├── types.ts ✅
|
||||
│ └── mockData.ts ✅
|
||||
│
|
||||
└── WorkerScreen/
|
||||
├── index.tsx ✅
|
||||
├── types.ts ✅
|
||||
├── WorkCard.tsx ✅
|
||||
├── ProcessDetailSection.tsx ✅
|
||||
├── MaterialInputModal.tsx ✅
|
||||
├── WorkLogModal.tsx ⚠️ 개선 필요
|
||||
├── IssueReportModal.tsx ✅
|
||||
├── CompletionConfirmDialog.tsx ✅
|
||||
└── CompletionToast.tsx ✅
|
||||
|
||||
src/components/ui/
|
||||
└── collapsible.tsx ✅ (신규 추가, @radix-ui/react-collapsible 설치됨)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 테스트 URL
|
||||
- 생산 현황판: http://localhost:3000/ko/production/dashboard
|
||||
- 작업자 화면: http://localhost:3000/ko/production/worker-screen
|
||||
|
||||
---
|
||||
|
||||
### 참고 사항
|
||||
1. **작업자 화면 = 별도 페이지** (생산 현황판 하위 아님)
|
||||
- 사이드바 메뉴로 접근
|
||||
- "돌아가기" 버튼 불필요
|
||||
|
||||
2. **모든 alert() → AlertDialog 변환 완료**
|
||||
- 전량완료 확인/성공
|
||||
- 이슈보고 벨리데이션/성공
|
||||
|
||||
3. **공정상세 = 카드 내 토글 확장**
|
||||
- Collapsible 컴포넌트 사용
|
||||
- 5단계 공정 표시
|
||||
|
||||
---
|
||||
|
||||
**작성일**: 2025-12-22
|
||||
**상태**: 🔄 작업일지 모달 개선 대기
|
||||
@@ -0,0 +1,159 @@
|
||||
# 검사관리 구현 체크리스트
|
||||
|
||||
> **URL**: `/quality/inspections`
|
||||
> **생성일**: 2025-12-23
|
||||
> **상태**: ✅ 완료
|
||||
|
||||
---
|
||||
|
||||
## 스크린샷 분석 요약
|
||||
|
||||
### 1. 검사 목록 (리스트)
|
||||
- **상단 카드**: 금일 대기 건수, 진행 중 검사, 금일 완료 건수, 불량 발생률(%)
|
||||
- **검색**: LOT번호/품목명/공정명 검색 + 날짜 범위 선택
|
||||
- **탭 필터**: 전체, 대기, 진행중, 완료
|
||||
- **테이블 컬럼**: No, 검사유형(IQC/PQC/FQC), 요청일, 품목명, LOT NO, 상태, 담당자
|
||||
- **버튼**: + 검사 등록
|
||||
|
||||
### 2. 검사 등록
|
||||
- **검사 개요**: LOT NO(자동), 품목명(자동), 공정명(자동), 수량, 작업자, 특이사항
|
||||
- **검사 기준 및 도해**: 템플릿 이미지 표시 영역
|
||||
- **검사 데이터 입력**:
|
||||
- 가공상태: 기준(Spec) + 양호/불량 라디오
|
||||
- 높이(H): 기준(Spec) + 측정값 입력(mm)
|
||||
- 길이(L): 기준(Spec) + 측정값 입력(mm)
|
||||
- 각 항목 우측에 "판정: 적합" 표시
|
||||
- **버튼**: 취소, 검사완료
|
||||
|
||||
### 3. 검사 상세
|
||||
- **헤더**: 검사번호 + 합격/불합격 배지, 성적서 버튼, 목록/수정 버튼
|
||||
- **검사 정보**: 검사번호, 검사유형, 검사일자, 판정결과, 품목명, LOT NO, 공정명, 검사자
|
||||
- **검사 결과 데이터 테이블**: 항목명, 기준(Spec), 측정값/결과, 판정(적합/부적합)
|
||||
- **종합 의견**: 텍스트 영역
|
||||
- **첨부 파일**: 파일 목록
|
||||
|
||||
### 4. 검사 수정
|
||||
- **검사 개요 (수정 불가)**: LOT NO, 품목명, 공정명, 수량 - 모두 disabled
|
||||
- **수정 사유 (필수 ★)**: textarea
|
||||
- **검사 데이터 수정**: 등록과 동일한 입력 폼
|
||||
- **버튼**: 취소, 수정 완료
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: 폴더 구조 및 기본 설정
|
||||
|
||||
- [x] 1.1 폴더 구조 생성
|
||||
- `src/app/[locale]/(protected)/quality/inspections/`
|
||||
- `src/components/quality/InspectionManagement/`
|
||||
- [x] 1.2 타입 정의 (`types.ts`)
|
||||
- [x] 1.3 mockData 생성 (`mockData.ts`)
|
||||
|
||||
## Phase 2: 검사 목록 (리스트) 페이지
|
||||
|
||||
- [x] 2.1 메인 페이지 컴포넌트 (`page.tsx`)
|
||||
- [x] 2.2 클라이언트 컴포넌트 (`InspectionList.tsx`)
|
||||
- [x] 2.3 상단 통계 카드 (4개)
|
||||
- 금일 대기 건수
|
||||
- 진행 중 검사
|
||||
- 금일 완료 건수
|
||||
- 불량 발생률
|
||||
- [x] 2.4 검색/필터 영역
|
||||
- LOT번호/품목명/공정명 검색
|
||||
- 날짜 범위 선택
|
||||
- [x] 2.5 탭 필터 (전체/대기/진행중/완료)
|
||||
- [x] 2.6 테이블 구현
|
||||
- 체크박스, No, 검사유형, 요청일, 품목명, LOT NO, 상태, 담당자
|
||||
- [x] 2.7 "+ 검사 등록" 버튼 → 등록 페이지 이동
|
||||
|
||||
## Phase 3: 검사 등록 페이지
|
||||
|
||||
- [x] 3.1 등록 페이지 라우트 (`new/page.tsx`)
|
||||
- [x] 3.2 검사 개요 섹션
|
||||
- LOT NO, 품목명, 공정명 (자동/읽기전용)
|
||||
- 수량, 작업자, 특이사항 (입력)
|
||||
- [x] 3.3 검사 기준 및 도해 섹션
|
||||
- 이미지 표시 영역
|
||||
- [x] 3.4 검사 데이터 입력 섹션
|
||||
- 동적 검사항목 폼
|
||||
- 가공상태: 양호/불량 라디오
|
||||
- 측정항목: 기준(Spec) + 측정값 입력
|
||||
- 자동 판정 로직 (기준값 범위 체크)
|
||||
- [x] 3.5 버튼: 취소, 검사완료
|
||||
- [x] 3.6 폼 유효성 검사 및 제출 로직
|
||||
|
||||
## Phase 4: 검사 상세 페이지
|
||||
|
||||
- [x] 4.1 상세 페이지 라우트 (`[id]/page.tsx`)
|
||||
- [x] 4.2 헤더 영역
|
||||
- 검사번호 + 합격/불합격 배지
|
||||
- 성적서 버튼
|
||||
- 목록/수정 버튼
|
||||
- [x] 4.3 검사 정보 섹션 (읽기 전용)
|
||||
- [x] 4.4 검사 결과 데이터 테이블
|
||||
- [x] 4.5 종합 의견 표시
|
||||
- [x] 4.6 첨부 파일 목록
|
||||
|
||||
## Phase 5: 검사 수정 페이지
|
||||
|
||||
- [x] 5.1 수정 모드 구현 (`?mode=edit` 쿼리 파라미터)
|
||||
- [x] 5.2 검사 개요 (수정 불가 - disabled)
|
||||
- [x] 5.3 수정 사유 입력 (필수)
|
||||
- [x] 5.4 검사 데이터 수정 폼 (기존 값 로드)
|
||||
- [x] 5.5 버튼: 취소, 수정 완료
|
||||
- [x] 5.6 수정 로직 및 유효성 검사
|
||||
|
||||
## Phase 6: 공통 기능
|
||||
|
||||
- [x] 6.1 상태 배지 컴포넌트 (대기/진행중/완료)
|
||||
- [x] 6.2 검사유형 배지 (IQC/PQC/FQC)
|
||||
- [x] 6.3 판정 로직 (기준값 범위 체크 → 적합/부적합)
|
||||
- [x] 6.4 측정값 자동 판정 표시
|
||||
- [x] 6.5 성적서 출력 기능 (버튼 및 로직 준비)
|
||||
|
||||
## Phase 7: 통합 및 테스트
|
||||
|
||||
- [x] 7.1 페이지 간 네비게이션 연결
|
||||
- [x] 7.2 빌드 테스트 (타입체크 통과)
|
||||
- [x] 7.3 테스트 URL 문서 업데이트
|
||||
|
||||
---
|
||||
|
||||
## 생성된 파일 목록
|
||||
|
||||
### 페이지 라우트
|
||||
| 파일 | 경로 |
|
||||
|------|------|
|
||||
| 검사 목록 | `src/app/[locale]/(protected)/quality/inspections/page.tsx` |
|
||||
| 검사 등록 | `src/app/[locale]/(protected)/quality/inspections/new/page.tsx` |
|
||||
| 검사 상세/수정 | `src/app/[locale]/(protected)/quality/inspections/[id]/page.tsx` |
|
||||
|
||||
### 컴포넌트
|
||||
| 파일 | 설명 |
|
||||
|------|------|
|
||||
| `types.ts` | 타입 정의 (InspectionType, InspectionItem 등) |
|
||||
| `mockData.ts` | Mock 데이터 및 judgeMeasurement 함수 |
|
||||
| `InspectionList.tsx` | 목록 페이지 (IntegratedListTemplateV2 사용) |
|
||||
| `InspectionCreate.tsx` | 등록 페이지 |
|
||||
| `InspectionDetail.tsx` | 상세/수정 페이지 (mode 쿼리 파라미터로 전환) |
|
||||
| `index.ts` | 컴포넌트 export |
|
||||
|
||||
---
|
||||
|
||||
## 테스트 URL
|
||||
|
||||
| 페이지 | URL |
|
||||
|--------|-----|
|
||||
| 검사 목록 | `http://localhost:3000/ko/quality/inspections` |
|
||||
| 검사 등록 | `http://localhost:3000/ko/quality/inspections/new` |
|
||||
| 검사 상세 | `http://localhost:3000/ko/quality/inspections/INS-001` |
|
||||
| 검사 수정 | `http://localhost:3000/ko/quality/inspections/INS-001?mode=edit` |
|
||||
|
||||
---
|
||||
|
||||
## 진행 로그
|
||||
|
||||
| 날짜 | 작업 내용 | 상태 |
|
||||
|------|----------|------|
|
||||
| 2025-12-23 | 체크리스트 생성, 스크린샷 분석 | ✅ |
|
||||
| 2025-12-23 | Phase 1-7 전체 구현 완료 | ✅ |
|
||||
| 2025-12-23 | 타입체크 통과, 문서 업데이트 | ✅ |
|
||||
624
claudedocs/sales/[IMPL-2025-12-22] order-management-sales.md
Normal file
624
claudedocs/sales/[IMPL-2025-12-22] order-management-sales.md
Normal file
@@ -0,0 +1,624 @@
|
||||
# 수주관리 (Order Management Sales) 구현 계획서
|
||||
|
||||
## 기본 정보
|
||||
|
||||
| 항목 | 내용 |
|
||||
|------|------|
|
||||
| **경로** | `/sales/order-management-sales` |
|
||||
| **상위 메뉴** | 판매관리 |
|
||||
| **작성일** | 2025-12-22 |
|
||||
| **상태** | Phase 2 완료 |
|
||||
|
||||
---
|
||||
|
||||
## 1. 페이지 구조
|
||||
|
||||
```
|
||||
/sales/order-management-sales
|
||||
├── page.tsx (리스트)
|
||||
├── new/page.tsx (등록)
|
||||
├── [id]/page.tsx (상세)
|
||||
├── [id]/edit/page.tsx (수정)
|
||||
├── [id]/production-order/page.tsx (생산지시 생성) ← TODO
|
||||
└── production-orders/ ← 생산지시 조회 (하위 경로)
|
||||
├── page.tsx (생산지시 목록)
|
||||
└── [id]/page.tsx (생산지시 상세)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. 리스트 화면
|
||||
|
||||
### 2.1 상단 통계 카드 (4개)
|
||||
| 카드 | 아이콘 | 값 형식 |
|
||||
|------|--------|---------|
|
||||
| 이번 달 수주 | $ | 금액 (예: 724,250,000원) |
|
||||
| 분할 대기 | ↔ | 건수 (예: 2건) |
|
||||
| 생산지시 대기 | 📋 | 건수 (예: 0건) |
|
||||
| 출하 대기 | 🚚 | 건수 (예: 14건) |
|
||||
|
||||
### 2.2 검색/필터
|
||||
- **검색창**: 로트번호, 견적번호, 발주처, 현장명 검색...
|
||||
- **필터 탭**: 전체, 수주등록, 수주확정, 생산지시완료, 미수
|
||||
|
||||
### 2.3 테이블 컬럼
|
||||
| 컬럼 | 설명 |
|
||||
|------|------|
|
||||
| 체크박스 | row 선택 |
|
||||
| 번호 | 순번 (1부터) |
|
||||
| 로트번호 | KD-TS-XXXXXX-XX |
|
||||
| 견적번호 | KD-PR-XXXXXX-XX |
|
||||
| 발주처 | 거래처명 |
|
||||
| 현장명 | 프로젝트/현장명 |
|
||||
| 상태 | 수주확정, 생산중, 출하완료 등 배지 |
|
||||
| 출고예정일 | YYYY-MM-DD |
|
||||
| 배송방식 | 직접배차, 상차 등 |
|
||||
|
||||
### 2.4 버튼
|
||||
- 우측 상단: `+ 수주 등록` 버튼
|
||||
|
||||
---
|
||||
|
||||
## 3. 등록 화면
|
||||
|
||||
### 3.1 견적 불러오기 섹션
|
||||
- 안내 문구: "확정된 견적을 선택하면 정보가 자동으로 채워집니다"
|
||||
- `견적 선택` 버튼
|
||||
- 선택된 견적 표시: 견적번호 + 등급 + 발주처/현장명/금액 + `해제` 버튼
|
||||
|
||||
### 3.2 기본 정보
|
||||
| 필드 | 타입 | 필수 |
|
||||
|------|------|------|
|
||||
| 발주처 | Select (드롭다운) | * |
|
||||
| 현장명 | Text | * |
|
||||
| 담당자 | Text | |
|
||||
| 연락처 | Phone | |
|
||||
|
||||
### 3.3 수주/배송 정보
|
||||
| 필드 | 타입 | 필수 |
|
||||
|------|------|------|
|
||||
| 출고예정일 | DatePicker + 미정 체크박스 | |
|
||||
| 납품요청일 | DatePicker + 미정 체크박스 | * |
|
||||
| 배송방식 | Select | |
|
||||
| 운임비용 | Select | |
|
||||
| 수신(반장/업체) | Text | * |
|
||||
| 수신처 연락처 | Phone | * |
|
||||
|
||||
### 3.4 수신처 주소
|
||||
- 우편번호 + `우편번호 찾기` 버튼
|
||||
- 기본 주소 (자동 입력)
|
||||
- 상세 주소 입력
|
||||
|
||||
### 3.5 비고
|
||||
- 특이사항 텍스트 영역
|
||||
|
||||
### 3.6 품목 내역
|
||||
| 컬럼 | 설명 |
|
||||
|------|------|
|
||||
| 순번 | 1, 2, 3... |
|
||||
| 품목코드 | PRD-X |
|
||||
| 품명 | 제품명 |
|
||||
| 종 | B1, B2 등 |
|
||||
| 부호 | C-01, C-02 등 |
|
||||
| 규격 | 4000×3000 등 |
|
||||
| 수량 | 숫자 |
|
||||
| 단위 | EA |
|
||||
| 단가 | 금액 |
|
||||
| 금액 | 단가 × 수량 |
|
||||
|
||||
- `+ 품목 추가` 버튼
|
||||
- 하단 합계: 소계, 할인율(%), 총금액
|
||||
|
||||
### 3.7 버튼
|
||||
- `취소` / `저장`
|
||||
|
||||
---
|
||||
|
||||
## 4. 팝업
|
||||
|
||||
### 4.1 견적 선택 팝업
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ 견적 선택 ✕ │
|
||||
├─────────────────────────────────────────┤
|
||||
│ 🔍 견적번호, 거래처, 현장명 검색... │
|
||||
├─────────────────────────────────────────┤
|
||||
│ 전환 가능한 견적 X건 (최종확정 상태) │
|
||||
│ │
|
||||
│ KD-PR-XXXXXX-XX A (우량) │
|
||||
│ 발주처명 │
|
||||
│ [현장명] 현장 이름 금액원 │
|
||||
│ X개 품목 │
|
||||
│ ─────────────────────────────────────── │
|
||||
│ KD-PR-XXXXXX-XX B (관리) │
|
||||
│ ... │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 4.2 품목 추가 팝업
|
||||
| 필드 | 타입 | 필수 | 설명 |
|
||||
|------|------|------|------|
|
||||
| 층 | Text | * | 몇 층 (예: 4층) |
|
||||
| 도면부호 | Text | * | 예: FSS1 |
|
||||
| 품목명 | Text | | 예: 국민방화스크린세터 |
|
||||
| **오픈사이즈 (고객 제공 치수)** | | | |
|
||||
| 가로 (mm) | Number | * | 예: 7260 |
|
||||
| 세로 (mm) | Number | * | 예: 2600 |
|
||||
| 가이드레일 타입 | Select | | 예: 백면형 (120-70) |
|
||||
| 마감 | Select | | 예: SUS마감 |
|
||||
| 단가 (원) | Number | | 예: 8000000 |
|
||||
|
||||
- `취소` / `추가` 버튼
|
||||
|
||||
### 4.3 수주 취소 팝업
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ ⊗ 수주 취소 │
|
||||
├─────────────────────────────────────────┤
|
||||
│ ┌─────────────────────────────────────┐ │
|
||||
│ │ 수주번호 KD-TS-251217-09 │ │
|
||||
│ │ 발주처 태영건설(주) │ │
|
||||
│ │ 현장명 데시앙 동탄 파크뷰 │ │
|
||||
│ │ 현재 상태 [재작업중] │ │
|
||||
│ └─────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ 취소 사유 * │
|
||||
│ [취소 사유를 선택하세요 ▼] │
|
||||
│ │
|
||||
│ 상세 사유 │
|
||||
│ ┌─────────────────────────────────────┐ │
|
||||
│ │ 취소 사유에 대한 상세 내용을 │ │
|
||||
│ │ 입력하세요 │ │
|
||||
│ └─────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────┐ │
|
||||
│ │ 취소 시 유의사항 │ │
|
||||
│ │ • 취소된 수주는 목록에서 '취소' │ │
|
||||
│ │ 상태로 표시됩니다 │ │
|
||||
│ │ • 취소 후에는 수정이 불가능합니다 │ │
|
||||
│ │ • 관련된 생산지시가 있는 경우 먼저 │ │
|
||||
│ │ 생산지시를 취소해야 합니다 │ │
|
||||
│ └─────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ [닫기] [⊗ 취소 확정] │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
| 필드 | 타입 | 필수 | 설명 |
|
||||
|------|------|------|------|
|
||||
| 수주번호 | Text (읽기전용) | | 취소할 수주번호 |
|
||||
| 발주처 | Text (읽기전용) | | 발주처명 |
|
||||
| 현장명 | Text (읽기전용) | | 현장명 |
|
||||
| 현재 상태 | Badge (읽기전용) | | 현재 수주 상태 |
|
||||
| 취소 사유 | Select | * | 드롭다운 선택 |
|
||||
| 상세 사유 | Textarea | | 상세 내용 입력 |
|
||||
|
||||
**버튼**: `닫기` / `⊗ 취소 확정`
|
||||
|
||||
**취소 시 유의사항**:
|
||||
- 취소된 수주는 목록에서 '취소' 상태로 표시됩니다
|
||||
- 취소 후에는 수정이 불가능합니다
|
||||
- 관련된 생산지시가 있는 경우 먼저 생산지시를 취소해야 합니다
|
||||
|
||||
---
|
||||
|
||||
## 5. 상세 화면
|
||||
|
||||
### 5.1 공통 구조
|
||||
- **좌측 상단 버튼**: 계약서, 거래명세서, 발주서 (클릭 시 모달 오픈)
|
||||
- **기본 정보**: 발주처, 현장명, 담당자, 연락처
|
||||
- **수주/배송 정보**: 수주일자, 출고예정일, 납품요청일, 배송방식, 운임비용, 수신, 수신처 연락처, 수신처 주소
|
||||
- **비고**: 특이사항
|
||||
- **제품 내역**: 테이블 (순번, 품목코드, 품명, 종, 부호, 규격, 수량, 단위, 단가, 금액)
|
||||
- **하단 합계**: 소계, 할인율, 총금액
|
||||
|
||||
### 5.2 상태별 버튼 차이
|
||||
|
||||
| 상태 | 우측 상단 버튼 |
|
||||
|------|---------------|
|
||||
| **출하완료** | `목록` |
|
||||
| **재작업중** | `목록`, `수정`, `생산지시 생성` (파란), `취소` |
|
||||
| **생산중** | `목록`, `수정`, `생산지시 생성` (파란), `취소` |
|
||||
| **수주확정** | `목록`, `수정`, `생산지시 생성` (파란), `취소` |
|
||||
| **생산지시완료** | `목록`, `수정`, `생산지시 생성` (파란) |
|
||||
| **작업완료** | `목록`, `수정`, `생산지시 생성` (파란), `취소` |
|
||||
|
||||
---
|
||||
|
||||
## 6. 문서 팝업 (계약서/거래명세서/발주서)
|
||||
|
||||
### 6.1 공통 헤더
|
||||
- PDF 다운로드, 이메일, 팩스, 인쇄, 닫기 버튼
|
||||
|
||||
### 6.2 계약서
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ 계약서 ✕ │
|
||||
│ [PDF] [이메일] [팩스] [■■] [인쇄] [닫기] │
|
||||
├─────────────────────────────────────────┤
|
||||
│ 계 약 서 │
|
||||
│ 수주번호: KD-TS-XXXXXX-XX │
|
||||
│ 계약일자: YYYY-MM-DD │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────┐ │
|
||||
│ │ 제품명 │ │
|
||||
│ │ 스크린 세터 (표준형) │ │
|
||||
│ └─────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────┐ │
|
||||
│ │ 수주물목 (개소별 사이즈) │ │
|
||||
│ │ 품목코드 │ 품명 │ 규격 │ 수량 │ 단위 │ │
|
||||
│ └─────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌──────────────┐ ┌──────────────┐ │
|
||||
│ │ 발주처정보 │ │ 당사정보 │ │
|
||||
│ │ 업체명 │ │ 업체명 │ │
|
||||
│ │ 대표자 │ │ 대표자 │ │
|
||||
│ │ 사업자번호 │ │ 사업자번호 │ │
|
||||
│ │ 연락처 │ │ 연락처 │ │
|
||||
│ │ 주소 │ │ 주소 │ │
|
||||
│ └──────────────┘ └──────────────┘ │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────┐ │
|
||||
│ │ 총 계약 금액 │ │
|
||||
│ │ ₩ 38,800,000 │ │
|
||||
│ │ (부가세 포함) │ │
|
||||
│ └─────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ 공급가액: XX,XXX,XXX원 할인율: X% │
|
||||
│ 할인액: -X,XXX,XXX원 │
|
||||
│ 할인 후 공급가액: XX,XXX,XXX원 │
|
||||
│ 부가세(10%): X,XXX,XXX원 │
|
||||
│ 합계: XX,XXX,XXX원 │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────┐ │
|
||||
│ │ 특이사항 │ │
|
||||
│ │ [내용] │ │
|
||||
│ └─────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 6.3 거래명세서
|
||||
- 공급자/공급받는자 정보 (상호, 대표자, 사업자번호, 연락처, 주소)
|
||||
- 품목내역 테이블 (순번, 품목코드, 품명, 규격, 수량, 단위, 단가, 공급가액)
|
||||
- 금액 계산 (공급가액, 할인율, 할인액, 할인 후 공급가액, 부가세, 합계 금액)
|
||||
- "위 금액을 거래하였음을 증명합니다."
|
||||
- 날짜 + 인
|
||||
|
||||
### 6.4 발주서
|
||||
- 로트번호 + 결재란 (작성/검토/승인/생산)
|
||||
- 신청업체 정보 (발주처, 담당자, FAX, 현장명)
|
||||
- 신청내용 (납기요청일, 출고일, 배송방법, 납품주소)
|
||||
- 부자재 테이블 (구분, 품명, 규격, 길이(mm), 수량, 비고)
|
||||
- 특이사항
|
||||
- 유의사항 (발주서 승인 후 작업 진행, 납기 엄수, 기타 문의사항)
|
||||
- 문의 연락처
|
||||
|
||||
---
|
||||
|
||||
## 7. 수정 화면
|
||||
|
||||
### 7.1 상단 정보
|
||||
- 제목: `수주 수정` + 수주번호 + 상태 배지
|
||||
|
||||
### 7.2 기본 정보 (읽기전용)
|
||||
| 필드 | 비고 |
|
||||
|------|------|
|
||||
| 로트번호 | 읽기전용 |
|
||||
| 견적번호 | 읽기전용 |
|
||||
| 담당자 | 읽기전용 |
|
||||
| 발주처 | 읽기전용 |
|
||||
| 현장명 | 읽기전용 |
|
||||
| 연락처 | 읽기전용 |
|
||||
|
||||
### 7.3 수주/배송 정보 (편집 가능)
|
||||
| 필드 | 타입 |
|
||||
|------|------|
|
||||
| 출고예정일 | DatePicker + 미정 체크박스 |
|
||||
| 납품요청일 | DatePicker |
|
||||
| 배송방식 | Select |
|
||||
| 운임비용 | Select |
|
||||
| 수신(반장/업체) | Text |
|
||||
| 수신처 연락처 | Phone |
|
||||
| 수신처 주소 | Text (전체 주소) |
|
||||
| 상세주소 | Text |
|
||||
|
||||
### 7.4 비고
|
||||
- 편집 가능 텍스트 영역
|
||||
|
||||
### 7.5 품목 내역
|
||||
- 안내 문구: `생산 시작 후 수정 불가`
|
||||
- 테이블 (No, 품목코드, 품명, 종, 부호, 규격(mm), 수량, 단위, 단가, 금액)
|
||||
- 하단 합계
|
||||
|
||||
### 7.6 버튼
|
||||
- `취소` / `저장`
|
||||
|
||||
---
|
||||
|
||||
## 8. 생산지시 생성 화면
|
||||
|
||||
### 8.1 페이지 제목
|
||||
- `생산지시 생성` + `2개 작업지시 생성 예정`
|
||||
|
||||
### 8.2 수주 정보
|
||||
| 필드 | 값 |
|
||||
|------|-----|
|
||||
| 수주번호 | KD-TS-XXXXXX-XX |
|
||||
| 품목 수 | X EA |
|
||||
| 총 수량 | X 개(품) |
|
||||
| 납기일 | YYYY-MM-DD |
|
||||
| 진행상태 | 배지 (예: 재작업중) |
|
||||
|
||||
### 8.3 생산지시 옵션
|
||||
| 필드 | 타입 | 옵션 |
|
||||
|------|------|------|
|
||||
| 우선순위 (필수) | Radio | 긴급 / 일반 / 분할 / VIP |
|
||||
| 비고 | Textarea | |
|
||||
| 납품요청일 | DatePicker | |
|
||||
| 생산라인 | Select | |
|
||||
| 생산지시 (필수) | Select | 작업지시 기본값 (공정) |
|
||||
| + 작업지시 일괄생성 | Button | |
|
||||
|
||||
### 8.4 메모
|
||||
- 생산지시 관련 메모 영역
|
||||
|
||||
### 8.5 생성될 작업지시 (X건)
|
||||
| 컬럼 | 설명 |
|
||||
|------|------|
|
||||
| 고정번호 | KD-PL-XXXXXX-XX |
|
||||
| 공정 | BCI |
|
||||
| 품목 수 | X EA |
|
||||
| 총 수량 | X EA |
|
||||
| 공정 수 | X |
|
||||
| BOM 자재(수량) | 1 BOM, 2 모재, X 자재소요, 6 BOM |
|
||||
| 시작일/완료일 | X 일 |
|
||||
|
||||
### 8.6 자재 소요량 및 재고 현황
|
||||
| 컬럼 | 설명 |
|
||||
|------|------|
|
||||
| 자재코드 | SCR-MAT-XXX |
|
||||
| 자재명 | 예: 스크린 원단 |
|
||||
| 단위 | M² / EA |
|
||||
| 소요량 | 숫자 |
|
||||
| 현재고 | 숫자 |
|
||||
| 상태 | 충분 (녹색) |
|
||||
|
||||
### 8.7 스크린 물류 내역 (X건)
|
||||
| 컬럼 | 설명 |
|
||||
|------|------|
|
||||
| No | 순번 |
|
||||
| 품목코드 | 품목 ID |
|
||||
| 품명 | 제품명 |
|
||||
| 가로/세로 | mm |
|
||||
| 가공수량 | 숫자 |
|
||||
| 재단면적 | m² |
|
||||
| 자투리(%) | 퍼센트 |
|
||||
| 자재코드 | 자재 ID |
|
||||
| 판재규격 | 규격 |
|
||||
| 판 | 숫자 |
|
||||
| 수량 | 숫자 |
|
||||
|
||||
### 8.8 모터/전장품 사양
|
||||
| 컬럼 | 설명 |
|
||||
|------|------|
|
||||
| 사이즈 (380V) | 예: KD-150K |
|
||||
| 모터 사양 | 예: 380-180 [3-4"] |
|
||||
| 허브 사양 | 예: 3"H |
|
||||
|
||||
### 8.9 필요한 BOM
|
||||
| 컬럼 | 설명 |
|
||||
|------|------|
|
||||
| 품목 | 품목명 |
|
||||
| 규격 | 예: 100-70 |
|
||||
| 조도 | 예: KSDEL/NAKED |
|
||||
| 단위 | 예: 3000 |
|
||||
| 수량 | 숫자 |
|
||||
|
||||
### 8.10 봉/카바
|
||||
| 컬럼 | 설명 |
|
||||
|------|------|
|
||||
| 카바(스테인리스커버) - 하단 조작 500-330 | |
|
||||
| 품목 | 품목명 |
|
||||
| 수량 | 숫자 |
|
||||
| | |
|
||||
| 봉/샤 | |
|
||||
| 품목 | 품목명 |
|
||||
| 수량 | 숫자 |
|
||||
| | |
|
||||
| 마/더 | |
|
||||
| 품목 | 품목명 |
|
||||
| 수량 | 숫자 |
|
||||
|
||||
### 8.11 박스/마감재
|
||||
| 컬럼 | 설명 |
|
||||
|------|------|
|
||||
| 품목 | 품목명 |
|
||||
| 규격 | 규격 |
|
||||
| 단위 | 단위 |
|
||||
| 수량 | 숫자 |
|
||||
|
||||
### 8.12 모터 브라켓
|
||||
| 컬럼 | 설명 |
|
||||
|------|------|
|
||||
| 품목 | 품목명 |
|
||||
| 수량 | 숫자 |
|
||||
|
||||
### 8.13 하단 버튼
|
||||
- `수주상세보기` / `생산지시 확정 (X건)` (파란 버튼)
|
||||
|
||||
---
|
||||
|
||||
## 9. 생산지시 확정 후 플로우
|
||||
|
||||
### 9.1 생산지시 확정 팝업
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ ✅ 생산지시가 생성되었습니다. │
|
||||
│ │
|
||||
│ 생산지시번호: PO-KD-TS-XXXXXX-XX │
|
||||
│ │
|
||||
│ 생산관리 > 생산지시 관리에서 │
|
||||
│ 작업지시서를 생성하세요. │
|
||||
│ │
|
||||
│ [확인] │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 9.2 생산지시 상세 화면 (확정 후)
|
||||
- **페이지 제목**: `생산지시 상세` + 생산지시번호 + 상태 배지 (생산대기)
|
||||
- **우측 상단**: `목록`, `작업지시 생성` 버튼
|
||||
|
||||
#### 공정 진행 현황
|
||||
- 진행 상태 바 또는 카드
|
||||
|
||||
#### 기본 정보
|
||||
| 필드 | 값 |
|
||||
|------|-----|
|
||||
| 생산지시번호 | PO-KD-TS-XXXXXX-XX |
|
||||
| 수주번호 | KD-TS-XXXXXX-XX |
|
||||
| 생산지시일 | YYYY-MM-DD |
|
||||
| 납기일 | YYYY-MM-DD |
|
||||
| 수량 | X 개 |
|
||||
|
||||
#### 거래처/현장 정보
|
||||
| 필드 | 값 |
|
||||
|------|-----|
|
||||
| 거래처 | 거래처명 |
|
||||
| 현장명 | 현장명 |
|
||||
| 제품유형 | (선택) |
|
||||
|
||||
#### BOM 품목별 공정 분류
|
||||
- 공정별 분류 표시
|
||||
|
||||
#### 작업지시서 목록
|
||||
| 컬럼 | 설명 |
|
||||
|------|------|
|
||||
| 작업지시번호 | KD-WO-XXXXXX-XX |
|
||||
| 공정 | 공정명 |
|
||||
| 수량 | X 개 |
|
||||
| 상태 | 배지 (예: 재작업중) |
|
||||
| 담당자 | - |
|
||||
|
||||
### 9.3 작업지시 자동 생성 팝업
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ ▷ 작업지시서 자동 생성 │
|
||||
├─────────────────────────────────────────┤
|
||||
│ │
|
||||
│ 다음 공정에 대한 작업지시서가 │
|
||||
│ 생성됩니다: │
|
||||
│ │
|
||||
│ 생성된 작업지시서는 생산팀에서 확인하고 │
|
||||
│ 작업을 진행할 수 있습니다. │
|
||||
│ │
|
||||
│ [취소] [작업지시 생성] │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 9.4 작업지시 생성 완료 팝업
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ ✅ X개의 작업지시서가 공정별로 │
|
||||
│ 자동 생성되었습니다. │
|
||||
│ │
|
||||
│ 생성된 작업지시서: │
|
||||
│ │
|
||||
│ 작업지시 관리 페이지로 이동합니다. │
|
||||
│ │
|
||||
│ [확인] │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- **확인 클릭 시**: 생산관리 > 작업지시 관리 리스트 페이지로 이동
|
||||
- (해당 페이지는 추후 구현 시 연결)
|
||||
|
||||
---
|
||||
|
||||
## 10. 컴포넌트 재사용
|
||||
|
||||
### 10.1 기존 컴포넌트 활용
|
||||
| 컴포넌트 | 용도 |
|
||||
|----------|------|
|
||||
| IntegratedListTemplateV2 | 리스트 페이지 |
|
||||
| PageLayout | 페이지 레이아웃 |
|
||||
| DocumentPreviewDialog | 문서 팝업 (기안함에서 사용 중) |
|
||||
| DaumPostcodeDialog | 우편번호 검색 |
|
||||
| AlertDialog | 확인 팝업 |
|
||||
| Dialog | 일반 팝업 |
|
||||
|
||||
### 10.2 신규 컴포넌트
|
||||
| 컴포넌트 | 용도 |
|
||||
|----------|------|
|
||||
| QuotationSelectDialog | 견적 선택 팝업 |
|
||||
| ItemAddDialog | 품목 추가 팝업 |
|
||||
| ContractDocument | 계약서 문서 |
|
||||
| TransactionDocument | 거래명세서 문서 |
|
||||
| PurchaseOrderDocument | 발주서 문서 |
|
||||
| ProductionOrderForm | 생산지시 생성 폼 |
|
||||
| WorkOrderConfirmDialog | 작업지시 생성 확인 팝업 |
|
||||
|
||||
---
|
||||
|
||||
## 11. API 엔드포인트 (예상)
|
||||
|
||||
| Method | Endpoint | 설명 |
|
||||
|--------|----------|------|
|
||||
| GET | /api/v1/order-management | 수주 목록 조회 |
|
||||
| GET | /api/v1/order-management/:id | 수주 상세 조회 |
|
||||
| POST | /api/v1/order-management | 수주 등록 |
|
||||
| PUT | /api/v1/order-management/:id | 수주 수정 |
|
||||
| DELETE | /api/v1/order-management/:id | 수주 삭제 |
|
||||
| GET | /api/v1/quotations/confirmed | 확정 견적 목록 |
|
||||
| POST | /api/v1/production-order | 생산지시 생성 |
|
||||
| POST | /api/v1/work-order | 작업지시 생성 |
|
||||
|
||||
---
|
||||
|
||||
## 12. 구현 순서
|
||||
|
||||
### Phase 1: 기본 CRUD ✅ 완료 (2025-12-22)
|
||||
- [x] 1.1 리스트 페이지 구현
|
||||
- [x] 1.2 등록 페이지 구현
|
||||
- [x] 1.3 견적 선택 팝업
|
||||
- [x] 1.4 품목 추가 팝업
|
||||
- [x] 1.5 상세 페이지 구현
|
||||
- [x] 1.6 수정 페이지 구현
|
||||
|
||||
### Phase 2: 문서 팝업 & 생산지시 조회 ✅ 완료 (2025-12-22)
|
||||
- [x] 2.1 계약서 문서 컴포넌트 (ContractDocument.tsx)
|
||||
- [x] 2.2 거래명세서 문서 컴포넌트 (TransactionDocument.tsx)
|
||||
- [x] 2.3 발주서 문서 컴포넌트 (PurchaseOrderDocument.tsx)
|
||||
- [x] 2.4 OrderDocumentModal 연동 (기안함 패턴 적용)
|
||||
- [x] 2.5 수주 상세 페이지 수정 (탭 → 버튼+모달)
|
||||
- [x] 2.6 생산지시 목록 페이지 (production-orders/page.tsx)
|
||||
- [x] 2.7 생산지시 상세 페이지 (production-orders/[id]/page.tsx)
|
||||
- [x] 2.8 생산지시완료 상태 버튼 변경 ("생산지시 생성" → "생산지시 보기")
|
||||
|
||||
### Phase 3: 생산지시 생성 연동 ← 현재
|
||||
- [ ] 3.1 생산지시 생성 페이지 ([id]/production-order/page.tsx)
|
||||
- [ ] 3.2 생산지시 확정 플로우 (확정 팝업)
|
||||
- [ ] 3.3 작업지시 생성 팝업
|
||||
- [ ] 3.4 페이지 이동 로직 (작업지시 관리로)
|
||||
|
||||
---
|
||||
|
||||
## 13. 참고 사항
|
||||
|
||||
### 13.1 상태 값 (6개)
|
||||
| 상태 | 배지 색상 | 설명 |
|
||||
|------|----------|------|
|
||||
| 수주확정 | 회색 | 초기 상태 |
|
||||
| 생산지시완료 | 파랑 | 생산지시 생성됨 |
|
||||
| 생산중 | 초록 | 생산 진행 중 |
|
||||
| 재작업중 | 주황 | 재작업 진행 중 |
|
||||
| 작업완료 | 파랑/완료 | 작업 완료 |
|
||||
| 출하완료 | 회색/완료 | 출하 완료 |
|
||||
|
||||
### 13.2 번호 체계
|
||||
| 유형 | 형식 | 예시 |
|
||||
|------|------|------|
|
||||
| 로트번호 | KD-TS-YYMMDD-XX | KD-TS-251217-09 |
|
||||
| 견적번호 | KD-PR-YYMMDD-XX | KD-PR-251217-09 |
|
||||
| 생산지시번호 | PO-KD-TS-YYMMDD-XX | PO-KD-TS-251217-09 |
|
||||
| 작업지시번호 | KD-WO-YYMMDD-XX | KD-WO-251217-11 |
|
||||
72
package-lock.json
generated
72
package-lock.json
generated
@@ -11,6 +11,7 @@
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-collapsible": "^1.1.12",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
@@ -1336,6 +1337,77 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-collapsible": {
|
||||
"version": "1.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.12.tgz",
|
||||
"integrity": "sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/primitive": "1.1.3",
|
||||
"@radix-ui/react-compose-refs": "1.1.2",
|
||||
"@radix-ui/react-context": "1.1.2",
|
||||
"@radix-ui/react-id": "1.1.1",
|
||||
"@radix-ui/react-presence": "1.1.5",
|
||||
"@radix-ui/react-primitive": "2.1.3",
|
||||
"@radix-ui/react-use-controllable-state": "1.2.2",
|
||||
"@radix-ui/react-use-layout-effect": "1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-collapsible/node_modules/@radix-ui/react-primitive": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
|
||||
"integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-slot": "1.2.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-collapsible/node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
||||
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-collection": {
|
||||
"version": "1.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz",
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-collapsible": "^1.1.12",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { ReceivingDetail } from '@/components/material/ReceivingManagement';
|
||||
|
||||
interface Props {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export default async function ReceivingDetailPage({ params }: Props) {
|
||||
const { id } = await params;
|
||||
return <ReceivingDetail id={id} />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { InspectionCreate } from '@/components/material/ReceivingManagement';
|
||||
|
||||
export default function InspectionPage() {
|
||||
return <InspectionCreate />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { ReceivingList } from '@/components/material/ReceivingManagement';
|
||||
|
||||
export default function ReceivingManagementPage() {
|
||||
return <ReceivingList />;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { StockStatusDetail } from '@/components/material/StockStatus';
|
||||
|
||||
interface StockStatusDetailPageProps {
|
||||
params: Promise<{
|
||||
id: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export default async function StockStatusDetailPage({ params }: StockStatusDetailPageProps) {
|
||||
const { id } = await params;
|
||||
return <StockStatusDetail id={id} />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { StockStatusList } from '@/components/material/StockStatus';
|
||||
|
||||
export default function StockStatusPage() {
|
||||
return <StockStatusList />;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* 출하관리 - 수정 페이지
|
||||
* URL: /outbound/shipments/[id]/edit
|
||||
*/
|
||||
|
||||
import { ShipmentEdit } from '@/components/outbound/ShipmentManagement';
|
||||
|
||||
interface ShipmentEditPageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export default async function ShipmentEditPage({ params }: ShipmentEditPageProps) {
|
||||
const { id } = await params;
|
||||
return <ShipmentEdit id={id} />;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* 출하관리 - 상세 페이지
|
||||
* URL: /outbound/shipments/[id]
|
||||
*/
|
||||
|
||||
import { ShipmentDetail } from '@/components/outbound/ShipmentManagement';
|
||||
|
||||
interface ShipmentDetailPageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export default async function ShipmentDetailPage({ params }: ShipmentDetailPageProps) {
|
||||
const { id } = await params;
|
||||
return <ShipmentDetail id={id} />;
|
||||
}
|
||||
10
src/app/[locale]/(protected)/outbound/shipments/new/page.tsx
Normal file
10
src/app/[locale]/(protected)/outbound/shipments/new/page.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* 출하관리 - 등록 페이지
|
||||
* URL: /outbound/shipments/new
|
||||
*/
|
||||
|
||||
import { ShipmentCreate } from '@/components/outbound/ShipmentManagement';
|
||||
|
||||
export default function NewShipmentPage() {
|
||||
return <ShipmentCreate />;
|
||||
}
|
||||
10
src/app/[locale]/(protected)/outbound/shipments/page.tsx
Normal file
10
src/app/[locale]/(protected)/outbound/shipments/page.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* 출하관리 - 목록 페이지
|
||||
* URL: /outbound/shipments
|
||||
*/
|
||||
|
||||
import { ShipmentList } from '@/components/outbound/ShipmentManagement';
|
||||
|
||||
export default function ShipmentsPage() {
|
||||
return <ShipmentList />;
|
||||
}
|
||||
21
src/app/[locale]/(protected)/production/dashboard/page.tsx
Normal file
21
src/app/[locale]/(protected)/production/dashboard/page.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* 생산 현황판 페이지
|
||||
*
|
||||
* 경로: /[locale]/(protected)/production/dashboard
|
||||
*/
|
||||
|
||||
import { Suspense } from 'react';
|
||||
import ProductionDashboard from '@/components/production/ProductionDashboard';
|
||||
|
||||
export default function ProductionDashboardPage() {
|
||||
return (
|
||||
<Suspense fallback={<div className="text-center py-8">로딩 중...</div>}>
|
||||
<ProductionDashboard />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
export const metadata = {
|
||||
title: '생산 현황판',
|
||||
description: '공장별 작업 현황을 확인합니다.',
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* 작업지시 상세 페이지
|
||||
* URL: /production/work-orders/[id]
|
||||
*/
|
||||
|
||||
import { WorkOrderDetail } from '@/components/production/WorkOrders';
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{
|
||||
id: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export default async function WorkOrderDetailPage({ params }: PageProps) {
|
||||
const { id } = await params;
|
||||
return <WorkOrderDetail orderId={id} />;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* 작업지시 등록 페이지
|
||||
* URL: /production/work-orders/create
|
||||
*/
|
||||
|
||||
import { WorkOrderCreate } from '@/components/production/WorkOrders';
|
||||
|
||||
export default function WorkOrderCreatePage() {
|
||||
return <WorkOrderCreate />;
|
||||
}
|
||||
10
src/app/[locale]/(protected)/production/work-orders/page.tsx
Normal file
10
src/app/[locale]/(protected)/production/work-orders/page.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* 작업지시 목록 페이지
|
||||
* URL: /production/work-orders
|
||||
*/
|
||||
|
||||
import { WorkOrderList } from '@/components/production/WorkOrders';
|
||||
|
||||
export default function WorkOrdersPage() {
|
||||
return <WorkOrderList />;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* 작업실적 조회 페이지
|
||||
* URL: /production/work-results
|
||||
*/
|
||||
|
||||
import { WorkResultList } from '@/components/production/WorkResults';
|
||||
|
||||
export default function WorkResultsPage() {
|
||||
return <WorkResultList />;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* 작업자 화면 페이지
|
||||
*
|
||||
* 경로: /[locale]/(protected)/production/worker-screen
|
||||
*/
|
||||
|
||||
import { Suspense } from 'react';
|
||||
import WorkerScreen from '@/components/production/WorkerScreen';
|
||||
|
||||
export default function WorkerScreenPage() {
|
||||
return (
|
||||
<Suspense fallback={<div className="text-center py-8">로딩 중...</div>}>
|
||||
<WorkerScreen />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
export const metadata = {
|
||||
title: '작업자 화면',
|
||||
description: '내 작업 목록을 확인하고 관리합니다.',
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* 검사 상세/수정 페이지
|
||||
* URL: /quality/inspections/[id]
|
||||
* 수정 모드: /quality/inspections/[id]?mode=edit
|
||||
*/
|
||||
|
||||
import { InspectionDetail } from '@/components/quality/InspectionManagement';
|
||||
|
||||
interface Props {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export default async function InspectionDetailPage({ params }: Props) {
|
||||
const { id } = await params;
|
||||
return <InspectionDetail id={id} />;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* 검사 등록 페이지
|
||||
* URL: /quality/inspections/new
|
||||
*/
|
||||
|
||||
import { InspectionCreate } from '@/components/quality/InspectionManagement';
|
||||
|
||||
export default function InspectionNewPage() {
|
||||
return <InspectionCreate />;
|
||||
}
|
||||
10
src/app/[locale]/(protected)/quality/inspections/page.tsx
Normal file
10
src/app/[locale]/(protected)/quality/inspections/page.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* 검사 목록 페이지
|
||||
* URL: /quality/inspections
|
||||
*/
|
||||
|
||||
import { InspectionList } from '@/components/quality/InspectionManagement';
|
||||
|
||||
export default function InspectionsPage() {
|
||||
return <InspectionList />;
|
||||
}
|
||||
@@ -0,0 +1,557 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 수주 수정 페이지
|
||||
*
|
||||
* - 기본 정보 (읽기전용)
|
||||
* - 수주/배송 정보 (편집 가능)
|
||||
* - 비고 (편집 가능)
|
||||
* - 품목 내역 (생산 시작 후 수정 불가)
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useRouter, useParams } from "next/navigation";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
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 {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { FileText, ArrowLeft, Info, AlertTriangle } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { PageLayout } from "@/components/organisms/PageLayout";
|
||||
import { PageHeader } from "@/components/organisms/PageHeader";
|
||||
import { FormSection } from "@/components/templates/ResponsiveFormTemplate";
|
||||
import { FormActions } from "@/components/organisms/FormActions";
|
||||
import { BadgeSm } from "@/components/atoms/BadgeSm";
|
||||
import { formatAmount } from "@/utils/formatAmount";
|
||||
import { OrderItem } from "@/components/orders";
|
||||
|
||||
// 수주 상태 타입
|
||||
type OrderStatus =
|
||||
| "order_registered"
|
||||
| "order_confirmed"
|
||||
| "production_ordered"
|
||||
| "in_production"
|
||||
| "rework"
|
||||
| "work_completed"
|
||||
| "shipped"
|
||||
| "cancelled";
|
||||
|
||||
// 수정 폼 데이터
|
||||
interface EditFormData {
|
||||
// 읽기전용 정보
|
||||
lotNumber: string;
|
||||
quoteNumber: string;
|
||||
client: string;
|
||||
siteName: string;
|
||||
manager: string;
|
||||
contact: string;
|
||||
status: OrderStatus;
|
||||
|
||||
// 수정 가능 정보
|
||||
expectedShipDate: string;
|
||||
expectedShipDateUndecided: boolean;
|
||||
deliveryRequestDate: string;
|
||||
deliveryMethod: string;
|
||||
shippingCost: string;
|
||||
receiver: string;
|
||||
receiverContact: string;
|
||||
address: string;
|
||||
addressDetail: string;
|
||||
remarks: string;
|
||||
|
||||
// 품목 (수정 제한)
|
||||
items: OrderItem[];
|
||||
canEditItems: boolean;
|
||||
subtotal: number;
|
||||
discountRate: number;
|
||||
totalAmount: number;
|
||||
}
|
||||
|
||||
// 배송방식 옵션
|
||||
const DELIVERY_METHODS = [
|
||||
{ value: "direct", label: "직접배차" },
|
||||
{ value: "pickup", label: "상차" },
|
||||
{ value: "courier", label: "택배" },
|
||||
];
|
||||
|
||||
// 운임비용 옵션
|
||||
const SHIPPING_COSTS = [
|
||||
{ value: "free", label: "무료" },
|
||||
{ value: "prepaid", label: "선불" },
|
||||
{ value: "collect", label: "착불" },
|
||||
{ value: "negotiable", label: "협의" },
|
||||
];
|
||||
|
||||
// 샘플 데이터
|
||||
const SAMPLE_ORDER: EditFormData = {
|
||||
lotNumber: "KD-TS-251217-01",
|
||||
quoteNumber: "KD-PR-251210-01",
|
||||
client: "태영건설(주)",
|
||||
siteName: "데시앙 동탄 파크뷰",
|
||||
manager: "김철수",
|
||||
contact: "010-1234-5678",
|
||||
status: "order_confirmed",
|
||||
expectedShipDate: "2025-01-15",
|
||||
expectedShipDateUndecided: false,
|
||||
deliveryRequestDate: "2025-01-20",
|
||||
deliveryMethod: "direct",
|
||||
shippingCost: "free",
|
||||
receiver: "박반장",
|
||||
receiverContact: "010-9876-5432",
|
||||
address: "경기도 화성시 동탄대로 123-45",
|
||||
addressDetail: "데시앙 동탄 파크뷰 현장",
|
||||
remarks: "4층 우선 납품 요청",
|
||||
items: [
|
||||
{
|
||||
id: "1",
|
||||
itemCode: "PRD-001",
|
||||
itemName: "국민방화스크린세터",
|
||||
type: "B1",
|
||||
symbol: "FSS1",
|
||||
spec: "7260×2600",
|
||||
width: 7260,
|
||||
height: 2600,
|
||||
quantity: 2,
|
||||
unit: "EA",
|
||||
unitPrice: 8000000,
|
||||
amount: 16000000,
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
itemCode: "PRD-002",
|
||||
itemName: "국민방화스크린세터",
|
||||
type: "B1",
|
||||
symbol: "FSS2",
|
||||
spec: "5000×2400",
|
||||
width: 5000,
|
||||
height: 2400,
|
||||
quantity: 3,
|
||||
unit: "EA",
|
||||
unitPrice: 7600000,
|
||||
amount: 22800000,
|
||||
},
|
||||
],
|
||||
canEditItems: true,
|
||||
subtotal: 38800000,
|
||||
discountRate: 0,
|
||||
totalAmount: 38800000,
|
||||
};
|
||||
|
||||
// 상태 뱃지 헬퍼
|
||||
function getOrderStatusBadge(status: OrderStatus) {
|
||||
const statusConfig: Record<OrderStatus, { label: string; className: string }> = {
|
||||
order_registered: { label: "수주등록", className: "bg-gray-100 text-gray-700 border-gray-200" },
|
||||
order_confirmed: { label: "수주확정", className: "bg-gray-100 text-gray-700 border-gray-200" },
|
||||
production_ordered: { label: "생산지시완료", className: "bg-blue-100 text-blue-700 border-blue-200" },
|
||||
in_production: { label: "생산중", className: "bg-green-100 text-green-700 border-green-200" },
|
||||
rework: { label: "재작업중", className: "bg-orange-100 text-orange-700 border-orange-200" },
|
||||
work_completed: { label: "작업완료", className: "bg-blue-600 text-white border-blue-600" },
|
||||
shipped: { label: "출하완료", className: "bg-gray-500 text-white border-gray-500" },
|
||||
cancelled: { label: "취소", className: "bg-red-100 text-red-700 border-red-200" },
|
||||
};
|
||||
const config = statusConfig[status];
|
||||
return (
|
||||
<BadgeSm className={config.className}>
|
||||
{config.label}
|
||||
</BadgeSm>
|
||||
);
|
||||
}
|
||||
|
||||
export default function OrderEditPage() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const orderId = params.id as string;
|
||||
|
||||
const [form, setForm] = useState<EditFormData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
// 데이터 로드
|
||||
useEffect(() => {
|
||||
setTimeout(() => {
|
||||
// 상태에 따라 품목 수정 가능 여부 결정
|
||||
const canEditItems = !["in_production", "rework", "work_completed", "shipped"].includes(
|
||||
SAMPLE_ORDER.status
|
||||
);
|
||||
setForm({ ...SAMPLE_ORDER, canEditItems });
|
||||
setLoading(false);
|
||||
}, 300);
|
||||
}, [orderId]);
|
||||
|
||||
const handleBack = () => {
|
||||
router.push(`/sales/order-management-sales/${orderId}`);
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
router.push(`/sales/order-management-sales/${orderId}`);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!form) return;
|
||||
|
||||
// 유효성 검사
|
||||
if (!form.deliveryRequestDate) {
|
||||
toast.error("납품요청일을 입력해주세요.");
|
||||
return;
|
||||
}
|
||||
if (!form.receiver.trim()) {
|
||||
toast.error("수신(반장/업체)을 입력해주세요.");
|
||||
return;
|
||||
}
|
||||
if (!form.receiverContact.trim()) {
|
||||
toast.error("수신처 연락처를 입력해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
try {
|
||||
// TODO: API 연동
|
||||
console.log("수주 수정 데이터:", form);
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
toast.success("수주가 수정되었습니다.");
|
||||
router.push(`/sales/order-management-sales/${orderId}`);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading || !form) {
|
||||
return (
|
||||
<PageLayout>
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
{/* 헤더 */}
|
||||
<PageHeader
|
||||
title="수주 수정"
|
||||
icon={FileText}
|
||||
actions={
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="text-sm font-mono bg-gray-100 px-2 py-1 rounded">
|
||||
{form.lotNumber}
|
||||
</code>
|
||||
{getOrderStatusBadge(form.status)}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* 기본 정보 (읽기전용) */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
기본 정보
|
||||
<span className="text-sm font-normal text-muted-foreground">(읽기전용)</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-muted-foreground text-sm">로트번호</Label>
|
||||
<p className="font-medium">{form.lotNumber}</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-muted-foreground text-sm">견적번호</Label>
|
||||
<p className="font-medium">{form.quoteNumber}</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-muted-foreground text-sm">담당자</Label>
|
||||
<p className="font-medium">{form.manager}</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-muted-foreground text-sm">발주처</Label>
|
||||
<p className="font-medium">{form.client}</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-muted-foreground text-sm">현장명</Label>
|
||||
<p className="font-medium">{form.siteName}</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-muted-foreground text-sm">연락처</Label>
|
||||
<p className="font-medium">{form.contact}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 수주/배송 정보 (편집 가능) */}
|
||||
<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>
|
||||
<div className="flex items-center gap-4">
|
||||
<Input
|
||||
type="date"
|
||||
value={form.expectedShipDate}
|
||||
onChange={(e) =>
|
||||
setForm({ ...form, expectedShipDate: e.target.value })
|
||||
}
|
||||
disabled={form.expectedShipDateUndecided}
|
||||
className="flex-1"
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="expectedShipDateUndecided"
|
||||
checked={form.expectedShipDateUndecided}
|
||||
onCheckedChange={(checked) =>
|
||||
setForm({
|
||||
...form,
|
||||
expectedShipDateUndecided: checked as boolean,
|
||||
expectedShipDate: checked ? "" : form.expectedShipDate,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<Label
|
||||
htmlFor="expectedShipDateUndecided"
|
||||
className="text-sm font-normal"
|
||||
>
|
||||
미정
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 납품요청일 */}
|
||||
<div className="space-y-2">
|
||||
<Label>
|
||||
납품요청일 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
type="date"
|
||||
value={form.deliveryRequestDate}
|
||||
onChange={(e) =>
|
||||
setForm({ ...form, deliveryRequestDate: e.target.value })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 배송방식 */}
|
||||
<div className="space-y-2">
|
||||
<Label>배송방식</Label>
|
||||
<Select
|
||||
value={form.deliveryMethod}
|
||||
onValueChange={(value) =>
|
||||
setForm({ ...form, deliveryMethod: value })
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="배송방식 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{DELIVERY_METHODS.map((method) => (
|
||||
<SelectItem key={method.value} value={method.value}>
|
||||
{method.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 운임비용 */}
|
||||
<div className="space-y-2">
|
||||
<Label>운임비용</Label>
|
||||
<Select
|
||||
value={form.shippingCost}
|
||||
onValueChange={(value) =>
|
||||
setForm({ ...form, shippingCost: value })
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="운임비용 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SHIPPING_COSTS.map((cost) => (
|
||||
<SelectItem key={cost.value} value={cost.value}>
|
||||
{cost.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 수신(반장/업체) */}
|
||||
<div className="space-y-2">
|
||||
<Label>
|
||||
수신(반장/업체) <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
value={form.receiver}
|
||||
onChange={(e) =>
|
||||
setForm({ ...form, receiver: e.target.value })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 수신처 연락처 */}
|
||||
<div className="space-y-2">
|
||||
<Label>
|
||||
수신처 연락처 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
value={form.receiverContact}
|
||||
onChange={(e) =>
|
||||
setForm({ ...form, receiverContact: e.target.value })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 수신처 주소 */}
|
||||
<div className="space-y-2 md:col-span-2">
|
||||
<Label>수신처 주소</Label>
|
||||
<Input
|
||||
value={form.address}
|
||||
onChange={(e) =>
|
||||
setForm({ ...form, address: e.target.value })
|
||||
}
|
||||
placeholder="주소"
|
||||
className="mb-2"
|
||||
/>
|
||||
<Input
|
||||
value={form.addressDetail}
|
||||
onChange={(e) =>
|
||||
setForm({ ...form, addressDetail: e.target.value })
|
||||
}
|
||||
placeholder="상세주소"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 비고 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">비고</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Textarea
|
||||
value={form.remarks}
|
||||
onChange={(e) => setForm({ ...form, remarks: e.target.value })}
|
||||
placeholder="특이사항을 입력하세요"
|
||||
rows={4}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 품목 내역 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
품목 내역
|
||||
{!form.canEditItems && (
|
||||
<span className="flex items-center gap-1 text-sm font-normal text-orange-600">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
생산 시작 후 수정 불가
|
||||
</span>
|
||||
)}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[60px] text-center">No</TableHead>
|
||||
<TableHead>품목코드</TableHead>
|
||||
<TableHead>품명</TableHead>
|
||||
<TableHead>종</TableHead>
|
||||
<TableHead>부호</TableHead>
|
||||
<TableHead>규격(mm)</TableHead>
|
||||
<TableHead className="text-center">수량</TableHead>
|
||||
<TableHead className="text-center">단위</TableHead>
|
||||
<TableHead className="text-right">단가</TableHead>
|
||||
<TableHead className="text-right">금액</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{form.items.map((item, index) => (
|
||||
<TableRow key={item.id}>
|
||||
<TableCell className="text-center">{index + 1}</TableCell>
|
||||
<TableCell>
|
||||
<code className="text-xs bg-gray-100 px-1.5 py-0.5 rounded">
|
||||
{item.itemCode}
|
||||
</code>
|
||||
</TableCell>
|
||||
<TableCell>{item.itemName}</TableCell>
|
||||
<TableCell>{item.type || "-"}</TableCell>
|
||||
<TableCell>{item.symbol || "-"}</TableCell>
|
||||
<TableCell>{item.spec}</TableCell>
|
||||
<TableCell className="text-center">{item.quantity}</TableCell>
|
||||
<TableCell className="text-center">{item.unit}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{formatAmount(item.unitPrice)}원
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-medium">
|
||||
{formatAmount(item.amount)}원
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* 합계 */}
|
||||
<div className="flex flex-col items-end gap-2 pt-4 mt-4 border-t">
|
||||
<div className="flex items-center gap-4 text-sm">
|
||||
<span className="text-muted-foreground">소계:</span>
|
||||
<span className="w-32 text-right">
|
||||
{formatAmount(form.subtotal)}원
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-sm">
|
||||
<span className="text-muted-foreground">할인율:</span>
|
||||
<span className="w-32 text-right">{form.discountRate}%</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-lg font-semibold">
|
||||
<span>총금액:</span>
|
||||
<span className="w-32 text-right text-green-600">
|
||||
{formatAmount(form.totalAmount)}원
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 하단 버튼 */}
|
||||
<div className="sticky bottom-0 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 border-t pt-4 pb-4 -mx-3 md:-mx-6 px-3 md:px-6 mt-6">
|
||||
<FormActions
|
||||
onSave={handleSave}
|
||||
onCancel={handleCancel}
|
||||
saveLabel="저장"
|
||||
cancelLabel="취소"
|
||||
saveLoading={isSaving}
|
||||
saveDisabled={isSaving}
|
||||
/>
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,782 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 수주 상세 페이지
|
||||
*
|
||||
* - 문서 모달: 계약서, 거래명세서, 발주서
|
||||
* - 기본 정보, 수주/배송 정보, 비고
|
||||
* - 제품 내역 테이블
|
||||
* - 상태별 버튼 차이
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useRouter, useParams } from "next/navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import {
|
||||
FileText,
|
||||
ArrowLeft,
|
||||
Edit,
|
||||
Factory,
|
||||
XCircle,
|
||||
FileSpreadsheet,
|
||||
FileCheck,
|
||||
ClipboardList,
|
||||
Eye,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { PageLayout } from "@/components/organisms/PageLayout";
|
||||
import { PageHeader } from "@/components/organisms/PageHeader";
|
||||
import { BadgeSm } from "@/components/atoms/BadgeSm";
|
||||
import { formatAmount } from "@/utils/formatAmount";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
OrderItem,
|
||||
OrderDocumentModal,
|
||||
type OrderDocumentType,
|
||||
} from "@/components/orders";
|
||||
|
||||
// 수주 상태 타입
|
||||
type OrderStatus =
|
||||
| "order_registered"
|
||||
| "order_confirmed"
|
||||
| "production_ordered"
|
||||
| "in_production"
|
||||
| "rework"
|
||||
| "work_completed"
|
||||
| "shipped"
|
||||
| "cancelled";
|
||||
|
||||
// 수주 상세 데이터 타입
|
||||
interface OrderDetail {
|
||||
id: string;
|
||||
lotNumber: string;
|
||||
quoteNumber: string;
|
||||
orderDate: string;
|
||||
status: OrderStatus;
|
||||
client: string;
|
||||
siteName: string;
|
||||
manager: string;
|
||||
contact: string;
|
||||
expectedShipDate: string;
|
||||
deliveryRequestDate: string;
|
||||
deliveryMethod: string;
|
||||
shippingCost: string;
|
||||
receiver: string;
|
||||
receiverContact: string;
|
||||
address: string;
|
||||
remarks: string;
|
||||
items: OrderItem[];
|
||||
subtotal: number;
|
||||
discountRate: number;
|
||||
totalAmount: number;
|
||||
}
|
||||
|
||||
// 샘플 품목 데이터
|
||||
const SAMPLE_ITEMS: OrderItem[] = [
|
||||
{
|
||||
id: "1",
|
||||
itemCode: "PRD-001",
|
||||
itemName: "국민방화스크린세터",
|
||||
type: "B1",
|
||||
symbol: "FSS1",
|
||||
spec: "7260×2600",
|
||||
width: 7260,
|
||||
height: 2600,
|
||||
quantity: 2,
|
||||
unit: "EA",
|
||||
unitPrice: 8000000,
|
||||
amount: 16000000,
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
itemCode: "PRD-002",
|
||||
itemName: "국민방화스크린세터",
|
||||
type: "B1",
|
||||
symbol: "FSS2",
|
||||
spec: "5000×2400",
|
||||
width: 5000,
|
||||
height: 2400,
|
||||
quantity: 3,
|
||||
unit: "EA",
|
||||
unitPrice: 7600000,
|
||||
amount: 22800000,
|
||||
},
|
||||
];
|
||||
|
||||
// 샘플 수주 데이터 (리스트 페이지와 동기화)
|
||||
const SAMPLE_ORDERS: Record<string, OrderDetail> = {
|
||||
"ORD-001": {
|
||||
id: "ORD-001",
|
||||
lotNumber: "KD-TS-251217-01",
|
||||
quoteNumber: "KD-PR-251210-01",
|
||||
orderDate: "2024-12-17",
|
||||
status: "order_confirmed",
|
||||
client: "태영건설(주)",
|
||||
siteName: "데시앙 동탄 파크뷰",
|
||||
manager: "김철수",
|
||||
contact: "010-1234-5678",
|
||||
expectedShipDate: "2025-01-15",
|
||||
deliveryRequestDate: "2025-01-20",
|
||||
deliveryMethod: "직접배차",
|
||||
shippingCost: "무료",
|
||||
receiver: "박반장",
|
||||
receiverContact: "010-9876-5432",
|
||||
address: "경기도 화성시 동탄대로 123-45 데시앙 동탄 파크뷰 현장",
|
||||
remarks: "4층 우선 납품 요청",
|
||||
items: SAMPLE_ITEMS,
|
||||
subtotal: 38800000,
|
||||
discountRate: 0,
|
||||
totalAmount: 38800000,
|
||||
},
|
||||
"ORD-002": {
|
||||
id: "ORD-002",
|
||||
lotNumber: "KD-TS-251217-02",
|
||||
quoteNumber: "KD-PR-251211-02",
|
||||
orderDate: "2024-12-17",
|
||||
status: "in_production",
|
||||
client: "현대건설(주)",
|
||||
siteName: "힐스테이트 판교역",
|
||||
manager: "이영희",
|
||||
contact: "010-2345-6789",
|
||||
expectedShipDate: "2025-01-20",
|
||||
deliveryRequestDate: "2025-01-25",
|
||||
deliveryMethod: "상차",
|
||||
shippingCost: "선불",
|
||||
receiver: "김반장",
|
||||
receiverContact: "010-8765-4321",
|
||||
address: "경기도 성남시 분당구 판교역로 123",
|
||||
remarks: "지하 1층 납품",
|
||||
items: SAMPLE_ITEMS,
|
||||
subtotal: 52500000,
|
||||
discountRate: 0,
|
||||
totalAmount: 52500000,
|
||||
},
|
||||
"ORD-003": {
|
||||
id: "ORD-003",
|
||||
lotNumber: "KD-TS-251216-01",
|
||||
quoteNumber: "KD-PR-251208-03",
|
||||
orderDate: "2024-12-16",
|
||||
status: "production_ordered",
|
||||
client: "GS건설(주)",
|
||||
siteName: "자이 강남센터",
|
||||
manager: "박민수",
|
||||
contact: "010-3456-7890",
|
||||
expectedShipDate: "2025-01-10",
|
||||
deliveryRequestDate: "2025-01-15",
|
||||
deliveryMethod: "직접배차",
|
||||
shippingCost: "무료",
|
||||
receiver: "최반장",
|
||||
receiverContact: "010-7654-3210",
|
||||
address: "서울시 강남구 테헤란로 234",
|
||||
remarks: "",
|
||||
items: SAMPLE_ITEMS,
|
||||
subtotal: 45000000,
|
||||
discountRate: 0,
|
||||
totalAmount: 45000000,
|
||||
},
|
||||
"ORD-004": {
|
||||
id: "ORD-004",
|
||||
lotNumber: "KD-TS-251215-01",
|
||||
quoteNumber: "KD-PR-251205-04",
|
||||
orderDate: "2024-12-15",
|
||||
status: "shipped",
|
||||
client: "대우건설(주)",
|
||||
siteName: "푸르지오 송도",
|
||||
manager: "정수진",
|
||||
contact: "010-4567-8901",
|
||||
expectedShipDate: "2024-12-20",
|
||||
deliveryRequestDate: "2024-12-22",
|
||||
deliveryMethod: "상차",
|
||||
shippingCost: "착불",
|
||||
receiver: "오반장",
|
||||
receiverContact: "010-6543-2109",
|
||||
address: "인천시 연수구 송도동 456",
|
||||
remarks: "출하 완료",
|
||||
items: SAMPLE_ITEMS,
|
||||
subtotal: 28900000,
|
||||
discountRate: 0,
|
||||
totalAmount: 28900000,
|
||||
},
|
||||
"ORD-005": {
|
||||
id: "ORD-005",
|
||||
lotNumber: "KD-TS-251214-01",
|
||||
quoteNumber: "KD-PR-251201-05",
|
||||
orderDate: "2024-12-14",
|
||||
status: "rework",
|
||||
client: "포스코건설",
|
||||
siteName: "더샵 분당센트럴",
|
||||
manager: "강호동",
|
||||
contact: "010-5678-9012",
|
||||
expectedShipDate: "2025-01-25",
|
||||
deliveryRequestDate: "2025-01-30",
|
||||
deliveryMethod: "직접배차",
|
||||
shippingCost: "무료",
|
||||
receiver: "유반장",
|
||||
receiverContact: "010-5432-1098",
|
||||
address: "경기도 성남시 분당구 정자동 789",
|
||||
remarks: "재작업 진행 중",
|
||||
items: SAMPLE_ITEMS,
|
||||
subtotal: 62000000,
|
||||
discountRate: 0,
|
||||
totalAmount: 62000000,
|
||||
},
|
||||
"ORD-006": {
|
||||
id: "ORD-006",
|
||||
lotNumber: "KD-TS-251213-01",
|
||||
quoteNumber: "KD-PR-251128-06",
|
||||
orderDate: "2024-12-13",
|
||||
status: "work_completed",
|
||||
client: "롯데건설(주)",
|
||||
siteName: "캐슬 잠실파크",
|
||||
manager: "신동엽",
|
||||
contact: "010-6789-0123",
|
||||
expectedShipDate: "2024-12-25",
|
||||
deliveryRequestDate: "2024-12-28",
|
||||
deliveryMethod: "직접배차",
|
||||
shippingCost: "무료",
|
||||
receiver: "한반장",
|
||||
receiverContact: "010-4321-0987",
|
||||
address: "서울시 송파구 잠실동 321",
|
||||
remarks: "작업 완료, 출하 대기",
|
||||
items: SAMPLE_ITEMS,
|
||||
subtotal: 35500000,
|
||||
discountRate: 0,
|
||||
totalAmount: 35500000,
|
||||
},
|
||||
"ORD-007": {
|
||||
id: "ORD-007",
|
||||
lotNumber: "KD-TS-251212-01",
|
||||
quoteNumber: "KD-PR-251125-07",
|
||||
orderDate: "2024-12-12",
|
||||
status: "order_registered",
|
||||
client: "삼성물산(주)",
|
||||
siteName: "래미안 서초",
|
||||
manager: "유재석",
|
||||
contact: "010-7890-1234",
|
||||
expectedShipDate: "",
|
||||
deliveryRequestDate: "2025-02-01",
|
||||
deliveryMethod: "",
|
||||
shippingCost: "",
|
||||
receiver: "이반장",
|
||||
receiverContact: "010-3210-9876",
|
||||
address: "서울시 서초구 서초동 654",
|
||||
remarks: "수주 등록 상태",
|
||||
items: SAMPLE_ITEMS,
|
||||
subtotal: 48000000,
|
||||
discountRate: 0,
|
||||
totalAmount: 48000000,
|
||||
},
|
||||
"ORD-008": {
|
||||
id: "ORD-008",
|
||||
lotNumber: "KD-TS-251211-01",
|
||||
quoteNumber: "KD-PR-251120-08",
|
||||
orderDate: "2024-12-11",
|
||||
status: "shipped",
|
||||
client: "SK에코플랜트",
|
||||
siteName: "SK VIEW 일산",
|
||||
manager: "하하",
|
||||
contact: "010-8901-2345",
|
||||
expectedShipDate: "2024-12-18",
|
||||
deliveryRequestDate: "2024-12-20",
|
||||
deliveryMethod: "상차",
|
||||
shippingCost: "선불",
|
||||
receiver: "조반장",
|
||||
receiverContact: "010-2109-8765",
|
||||
address: "경기도 고양시 일산서구 주엽동 987",
|
||||
remarks: "출하 완료, 미수금 있음",
|
||||
items: SAMPLE_ITEMS,
|
||||
subtotal: 31200000,
|
||||
discountRate: 0,
|
||||
totalAmount: 31200000,
|
||||
},
|
||||
};
|
||||
|
||||
// 상태 뱃지 헬퍼
|
||||
function getOrderStatusBadge(status: OrderStatus) {
|
||||
const statusConfig: Record<OrderStatus, { label: string; className: string }> = {
|
||||
order_registered: { label: "수주등록", className: "bg-gray-100 text-gray-700 border-gray-200" },
|
||||
order_confirmed: { label: "수주확정", className: "bg-gray-100 text-gray-700 border-gray-200" },
|
||||
production_ordered: { label: "생산지시완료", className: "bg-blue-100 text-blue-700 border-blue-200" },
|
||||
in_production: { label: "생산중", className: "bg-green-100 text-green-700 border-green-200" },
|
||||
rework: { label: "재작업중", className: "bg-orange-100 text-orange-700 border-orange-200" },
|
||||
work_completed: { label: "작업완료", className: "bg-blue-600 text-white border-blue-600" },
|
||||
shipped: { label: "출하완료", className: "bg-gray-500 text-white border-gray-500" },
|
||||
cancelled: { label: "취소", className: "bg-red-100 text-red-700 border-red-200" },
|
||||
};
|
||||
const config = statusConfig[status];
|
||||
return (
|
||||
<BadgeSm className={config.className}>
|
||||
{config.label}
|
||||
</BadgeSm>
|
||||
);
|
||||
}
|
||||
|
||||
// 정보 표시 컴포넌트
|
||||
function InfoItem({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">{label}</p>
|
||||
<p className="font-medium">{value || "-"}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function OrderDetailPage() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const orderId = params.id as string;
|
||||
|
||||
const [order, setOrder] = useState<OrderDetail | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [isCancelDialogOpen, setIsCancelDialogOpen] = useState(false);
|
||||
|
||||
// 취소 폼 상태
|
||||
const [cancelReason, setCancelReason] = useState("");
|
||||
const [cancelDetail, setCancelDetail] = useState("");
|
||||
|
||||
// 문서 모달 상태
|
||||
const [documentModalOpen, setDocumentModalOpen] = useState(false);
|
||||
const [documentType, setDocumentType] = useState<OrderDocumentType>("contract");
|
||||
|
||||
// 데이터 로드 (샘플 - ID로 매칭)
|
||||
useEffect(() => {
|
||||
// 실제 구현에서는 API 호출
|
||||
setTimeout(() => {
|
||||
const foundOrder = SAMPLE_ORDERS[orderId];
|
||||
setOrder(foundOrder || null);
|
||||
setLoading(false);
|
||||
}, 300);
|
||||
}, [orderId]);
|
||||
|
||||
const handleBack = () => {
|
||||
router.push("/sales/order-management-sales");
|
||||
};
|
||||
|
||||
const handleEdit = () => {
|
||||
router.push(`/sales/order-management-sales/${orderId}/edit`);
|
||||
};
|
||||
|
||||
const handleProductionOrder = () => {
|
||||
// 생산지시 생성 페이지로 이동
|
||||
router.push(`/sales/order-management-sales/${orderId}/production-order`);
|
||||
};
|
||||
|
||||
const handleViewProductionOrder = () => {
|
||||
// 생산지시 목록 페이지로 이동 (수주관리 내부)
|
||||
router.push(`/sales/order-management-sales/production-orders`);
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setCancelReason("");
|
||||
setCancelDetail("");
|
||||
setIsCancelDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleConfirmCancel = () => {
|
||||
if (!cancelReason) {
|
||||
toast.error("취소 사유를 선택해주세요.");
|
||||
return;
|
||||
}
|
||||
if (order) {
|
||||
setOrder({ ...order, status: "cancelled" });
|
||||
toast.success("수주가 취소되었습니다.");
|
||||
}
|
||||
setIsCancelDialogOpen(false);
|
||||
setCancelReason("");
|
||||
setCancelDetail("");
|
||||
};
|
||||
|
||||
// 문서 모달 열기
|
||||
const openDocumentModal = (type: OrderDocumentType) => {
|
||||
setDocumentType(type);
|
||||
setDocumentModalOpen(true);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<PageLayout>
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
if (!order) {
|
||||
return (
|
||||
<PageLayout>
|
||||
<div className="text-center py-12">
|
||||
<p className="text-muted-foreground">수주 정보를 찾을 수 없습니다.</p>
|
||||
<Button variant="outline" onClick={handleBack} className="mt-4">
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
목록으로
|
||||
</Button>
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
// 상태별 버튼 표시 여부
|
||||
const showEditButton = order.status !== "shipped" && order.status !== "cancelled";
|
||||
// 생산지시 생성 버튼: 출하완료, 취소, 생산지시완료 제외하고 표시
|
||||
// (수주등록, 수주확정, 생산중, 재작업중, 작업완료에서 표시)
|
||||
const showProductionCreateButton =
|
||||
order.status !== "shipped" &&
|
||||
order.status !== "cancelled" &&
|
||||
order.status !== "production_ordered";
|
||||
// 생산지시 보기 버튼: 생산지시완료 상태에서 숨김 (기획서 오류로 제거)
|
||||
const showProductionViewButton = false;
|
||||
const showCancelButton =
|
||||
order.status !== "shipped" &&
|
||||
order.status !== "cancelled" &&
|
||||
order.status !== "production_ordered";
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
{/* 헤더 */}
|
||||
<PageHeader
|
||||
title="수주 상세"
|
||||
icon={FileText}
|
||||
actions={
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Button variant="outline" onClick={handleBack}>
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
목록
|
||||
</Button>
|
||||
{showEditButton && (
|
||||
<Button variant="outline" onClick={handleEdit}>
|
||||
<Edit className="h-4 w-4 mr-2" />
|
||||
수정
|
||||
</Button>
|
||||
)}
|
||||
{showProductionCreateButton && (
|
||||
<Button onClick={handleProductionOrder}>
|
||||
<Factory className="h-4 w-4 mr-2" />
|
||||
생산지시 생성
|
||||
</Button>
|
||||
)}
|
||||
{showProductionViewButton && (
|
||||
<Button onClick={handleViewProductionOrder}>
|
||||
<Eye className="h-4 w-4 mr-2" />
|
||||
생산지시 보기
|
||||
</Button>
|
||||
)}
|
||||
{showCancelButton && (
|
||||
<Button variant="outline" onClick={handleCancel} className="border-orange-200 text-orange-600 hover:border-orange-300">
|
||||
<XCircle className="h-4 w-4 mr-2" />
|
||||
취소
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* 수주 정보 헤더 */}
|
||||
<Card>
|
||||
<CardHeader className="pb-4">
|
||||
<div className="flex items-center justify-between flex-wrap gap-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<code className="text-sm font-mono bg-gray-100 px-2 py-1 rounded">
|
||||
{order.lotNumber}
|
||||
</code>
|
||||
{getOrderStatusBadge(order.status)}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
수주일: {order.orderDate}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
{/* 문서 버튼들 */}
|
||||
<div className="flex items-center gap-2 pt-4 border-t">
|
||||
<span className="text-sm text-muted-foreground mr-2">문서:</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => openDocumentModal("contract")}
|
||||
>
|
||||
<FileCheck className="h-4 w-4 mr-1" />
|
||||
계약서
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => openDocumentModal("transaction")}
|
||||
>
|
||||
<FileSpreadsheet className="h-4 w-4 mr-1" />
|
||||
거래명세서
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => openDocumentModal("purchaseOrder")}
|
||||
>
|
||||
<ClipboardList className="h-4 w-4 mr-1" />
|
||||
발주서
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 기본 정보 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">기본 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-6">
|
||||
<InfoItem label="발주처" value={order.client} />
|
||||
<InfoItem label="현장명" value={order.siteName} />
|
||||
<InfoItem label="담당자" value={order.manager} />
|
||||
<InfoItem label="연락처" value={order.contact} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 수주/배송 정보 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">수주/배송 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-6">
|
||||
<InfoItem label="수주일자" value={order.orderDate} />
|
||||
<InfoItem label="출고예정일" value={order.expectedShipDate || "미정"} />
|
||||
<InfoItem label="납품요청일" value={order.deliveryRequestDate} />
|
||||
<InfoItem label="배송방식" value={order.deliveryMethod} />
|
||||
<InfoItem label="운임비용" value={order.shippingCost} />
|
||||
<InfoItem label="수신(반장/업체)" value={order.receiver} />
|
||||
<InfoItem label="수신처 연락처" value={order.receiverContact} />
|
||||
<InfoItem label="수신처 주소" value={order.address} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 비고 */}
|
||||
{order.remarks && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">비고</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="whitespace-pre-wrap">{order.remarks}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 제품 내역 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">제품 내역</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[60px] text-center">순번</TableHead>
|
||||
<TableHead>품목코드</TableHead>
|
||||
<TableHead>품명</TableHead>
|
||||
<TableHead>층</TableHead>
|
||||
<TableHead>부호</TableHead>
|
||||
<TableHead>규격</TableHead>
|
||||
<TableHead className="text-center">수량</TableHead>
|
||||
<TableHead className="text-center">단위</TableHead>
|
||||
<TableHead className="text-right">단가</TableHead>
|
||||
<TableHead className="text-right">금액</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{order.items.map((item, index) => (
|
||||
<TableRow key={item.id}>
|
||||
<TableCell className="text-center">{index + 1}</TableCell>
|
||||
<TableCell>
|
||||
<code className="text-xs bg-gray-100 px-1.5 py-0.5 rounded">
|
||||
{item.itemCode}
|
||||
</code>
|
||||
</TableCell>
|
||||
<TableCell>{item.itemName}</TableCell>
|
||||
<TableCell>{item.type || "-"}</TableCell>
|
||||
<TableCell>{item.symbol || "-"}</TableCell>
|
||||
<TableCell>{item.spec}</TableCell>
|
||||
<TableCell className="text-center">{item.quantity}</TableCell>
|
||||
<TableCell className="text-center">{item.unit}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{formatAmount(item.unitPrice)}원
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-medium">
|
||||
{formatAmount(item.amount)}원
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* 합계 */}
|
||||
<div className="flex flex-col items-end gap-2 pt-4 mt-4 border-t">
|
||||
<div className="flex items-center gap-4 text-sm">
|
||||
<span className="text-muted-foreground">소계:</span>
|
||||
<span className="w-32 text-right">
|
||||
{formatAmount(order.subtotal)}원
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-sm">
|
||||
<span className="text-muted-foreground">할인율:</span>
|
||||
<span className="w-32 text-right">{order.discountRate}%</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-lg font-semibold">
|
||||
<span>총금액:</span>
|
||||
<span className="w-32 text-right text-green-600">
|
||||
{formatAmount(order.totalAmount)}원
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 문서 모달 */}
|
||||
<OrderDocumentModal
|
||||
open={documentModalOpen}
|
||||
onOpenChange={setDocumentModalOpen}
|
||||
documentType={documentType}
|
||||
data={{
|
||||
lotNumber: order.lotNumber,
|
||||
orderDate: order.orderDate,
|
||||
client: order.client,
|
||||
siteName: order.siteName,
|
||||
manager: order.manager,
|
||||
managerContact: order.contact,
|
||||
deliveryRequestDate: order.deliveryRequestDate,
|
||||
expectedShipDate: order.expectedShipDate,
|
||||
deliveryMethod: order.deliveryMethod,
|
||||
address: order.address,
|
||||
items: order.items,
|
||||
subtotal: order.subtotal,
|
||||
discountRate: order.discountRate,
|
||||
totalAmount: order.totalAmount,
|
||||
remarks: order.remarks,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 취소 확인 다이얼로그 */}
|
||||
<Dialog open={isCancelDialogOpen} onOpenChange={setIsCancelDialogOpen}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<XCircle className="h-5 w-5" />
|
||||
수주 취소
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* 수주 정보 박스 */}
|
||||
<div className="border rounded-lg p-4 space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">수주번호</span>
|
||||
<span className="font-medium">{order.lotNumber}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">발주처</span>
|
||||
<span>{order.client}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">현장명</span>
|
||||
<span>{order.siteName}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-muted-foreground">현재 상태</span>
|
||||
{getOrderStatusBadge(order.status)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 취소 사유 선택 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="cancelReason">
|
||||
취소 사유 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Select value={cancelReason} onValueChange={setCancelReason}>
|
||||
<SelectTrigger id="cancelReason">
|
||||
<SelectValue placeholder="취소 사유를 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="customer_request">고객 요청</SelectItem>
|
||||
<SelectItem value="spec_change">사양 변경</SelectItem>
|
||||
<SelectItem value="price_issue">가격 문제</SelectItem>
|
||||
<SelectItem value="delivery_issue">납기 문제</SelectItem>
|
||||
<SelectItem value="duplicate_order">중복 수주</SelectItem>
|
||||
<SelectItem value="other">기타</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 상세 사유 입력 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="cancelDetail">상세 사유</Label>
|
||||
<Textarea
|
||||
id="cancelDetail"
|
||||
placeholder="취소 사유에 대한 상세 내용을 입력하세요"
|
||||
value={cancelDetail}
|
||||
onChange={(e) => setCancelDetail(e.target.value)}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 취소 시 유의사항 */}
|
||||
<div className="bg-gray-50 border rounded-lg p-4 text-sm space-y-1">
|
||||
<p className="font-medium mb-2">취소 시 유의사항</p>
|
||||
<ul className="space-y-1 text-muted-foreground">
|
||||
<li>• 취소된 수주는 목록에서 '취소' 상태로 표시됩니다</li>
|
||||
<li>• 취소 후에는 수정이 불가능합니다</li>
|
||||
<li>• 관련된 생산지시가 있는 경우 먼저 생산지시를 취소해야 합니다</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setIsCancelDialogOpen(false)}
|
||||
>
|
||||
닫기
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleConfirmCancel}
|
||||
className="border-gray-300"
|
||||
>
|
||||
<XCircle className="h-4 w-4 mr-1" />
|
||||
취소 확정
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,920 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 생산지시 생성 페이지
|
||||
*
|
||||
* - 수주 정보 (읽기전용)
|
||||
* - 생산지시 옵션 (우선순위 탭, 메모)
|
||||
* - 생성될 작업지시 (카드 형태)
|
||||
* - 자재 소요량 및 재고 현황
|
||||
* - 스크린 품목 상세
|
||||
* - 모터/전장품 사양 (읽기전용)
|
||||
* - 절곡물 BOM
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useRouter, useParams } from "next/navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Factory, ArrowLeft, BarChart3, CheckCircle2 } from "lucide-react";
|
||||
import { PageLayout } from "@/components/organisms/PageLayout";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { PageHeader } from "@/components/organisms/PageHeader";
|
||||
import { BadgeSm } from "@/components/atoms/BadgeSm";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// 수주 정보 타입
|
||||
interface OrderInfo {
|
||||
orderNumber: string;
|
||||
client: string;
|
||||
siteName: string;
|
||||
dueDate: string;
|
||||
itemCount: number;
|
||||
totalQuantity: string;
|
||||
creditGrade: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
// 우선순위 설정 타입
|
||||
type PriorityLevel = "urgent" | "high" | "normal" | "low";
|
||||
|
||||
interface PriorityConfig {
|
||||
key: PriorityLevel;
|
||||
label: string;
|
||||
productionOrder: string;
|
||||
workOrderPriority: string;
|
||||
note: string;
|
||||
}
|
||||
|
||||
// 작업지시 카드 타입
|
||||
interface WorkOrderCard {
|
||||
id: string;
|
||||
type: string;
|
||||
orderNumber: string;
|
||||
itemCount: number;
|
||||
totalQuantity: string;
|
||||
processes: string[];
|
||||
}
|
||||
|
||||
// 자재 소요량 타입
|
||||
interface MaterialRequirement {
|
||||
materialCode: string;
|
||||
materialName: string;
|
||||
unit: string;
|
||||
required: number;
|
||||
currentStock: number;
|
||||
status: "sufficient" | "insufficient";
|
||||
}
|
||||
|
||||
// 스크린 품목 상세 타입
|
||||
interface ScreenItemDetail {
|
||||
no: number;
|
||||
itemName: string;
|
||||
location: string;
|
||||
openWidth: number;
|
||||
openHeight: number;
|
||||
productWidth: number;
|
||||
productHeight: number;
|
||||
guideRail: string;
|
||||
shaft: string;
|
||||
capacity: string;
|
||||
finish: string;
|
||||
quantity: number;
|
||||
}
|
||||
|
||||
// 가이드레일 BOM 타입
|
||||
interface GuideRailBom {
|
||||
type: string;
|
||||
spec: string;
|
||||
code: string;
|
||||
length: number;
|
||||
quantity: number;
|
||||
}
|
||||
|
||||
// 케이스 BOM 타입
|
||||
interface CaseBom {
|
||||
item: string;
|
||||
length: string;
|
||||
quantity: number;
|
||||
}
|
||||
|
||||
// 하단 마감재 BOM 타입
|
||||
interface BottomFinishBom {
|
||||
item: string;
|
||||
spec: string;
|
||||
length: string;
|
||||
quantity: number;
|
||||
}
|
||||
|
||||
// 우선순위별 색상 설정
|
||||
const PRIORITY_COLORS: Record<PriorityLevel, { bg: string; text: string; border: string; button: string; buttonActive: string }> = {
|
||||
urgent: {
|
||||
bg: "bg-red-50",
|
||||
text: "text-red-700",
|
||||
border: "border-red-200",
|
||||
button: "bg-red-100 text-red-700 hover:bg-red-200",
|
||||
buttonActive: "bg-red-600 text-white",
|
||||
},
|
||||
high: {
|
||||
bg: "bg-orange-50",
|
||||
text: "text-orange-700",
|
||||
border: "border-orange-200",
|
||||
button: "bg-orange-100 text-orange-700 hover:bg-orange-200",
|
||||
buttonActive: "bg-orange-500 text-white",
|
||||
},
|
||||
normal: {
|
||||
bg: "bg-blue-50",
|
||||
text: "text-blue-700",
|
||||
border: "border-blue-200",
|
||||
button: "bg-blue-100 text-blue-700 hover:bg-blue-200",
|
||||
buttonActive: "bg-blue-600 text-white",
|
||||
},
|
||||
low: {
|
||||
bg: "bg-gray-50",
|
||||
text: "text-gray-700",
|
||||
border: "border-gray-200",
|
||||
button: "bg-gray-100 text-gray-700 hover:bg-gray-200",
|
||||
buttonActive: "bg-gray-600 text-white",
|
||||
},
|
||||
};
|
||||
|
||||
// 우선순위 설정 데이터
|
||||
const PRIORITY_CONFIGS: PriorityConfig[] = [
|
||||
{
|
||||
key: "urgent",
|
||||
label: "긴급",
|
||||
productionOrder: "긴급",
|
||||
workOrderPriority: "1순위",
|
||||
note: "무조건 제일 먼저",
|
||||
},
|
||||
{
|
||||
key: "high",
|
||||
label: "높음",
|
||||
productionOrder: "높음",
|
||||
workOrderPriority: "3순위",
|
||||
note: "(2순위는 현장에서 '새치기' 할 때 쓰도록 비워둠)",
|
||||
},
|
||||
{
|
||||
key: "normal",
|
||||
label: "일반",
|
||||
productionOrder: "일반",
|
||||
workOrderPriority: "5순위",
|
||||
note: "(4순위 비워둠)",
|
||||
},
|
||||
{
|
||||
key: "low",
|
||||
label: "낮음",
|
||||
productionOrder: "낮음",
|
||||
workOrderPriority: "9순위",
|
||||
note: "뒤로 쪽 밀어둠",
|
||||
},
|
||||
];
|
||||
|
||||
// 샘플 수주 정보
|
||||
const SAMPLE_ORDER_INFO: OrderInfo = {
|
||||
orderNumber: "KD-TS-251217-09",
|
||||
client: "태영건설(주)",
|
||||
siteName: "데시앙 동탄 파크뷰",
|
||||
dueDate: "2026-02-25",
|
||||
itemCount: 3,
|
||||
totalQuantity: "3EA",
|
||||
creditGrade: "A (우량)",
|
||||
status: "재작업중",
|
||||
};
|
||||
|
||||
// 샘플 작업지시 카드
|
||||
const SAMPLE_WORK_ORDER_CARDS: WorkOrderCard[] = [
|
||||
{
|
||||
id: "1",
|
||||
type: "스크린",
|
||||
orderNumber: "KD-PL-251223-01",
|
||||
itemCount: 3,
|
||||
totalQuantity: "3EA",
|
||||
processes: ["1. 원단절단", "2. 미싱", "3. 앤드락작업", "4. 중간검사", "5. 포장"],
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
type: "절곡",
|
||||
orderNumber: "KD-PL-251223-02",
|
||||
itemCount: 3,
|
||||
totalQuantity: "3EA",
|
||||
processes: ["1. 절단", "2. 절곡", "3. 중간검사", "4. 포장"],
|
||||
},
|
||||
];
|
||||
|
||||
// 샘플 자재 소요량
|
||||
const SAMPLE_MATERIALS: MaterialRequirement[] = [
|
||||
{
|
||||
materialCode: "SCR-MAT-001",
|
||||
materialName: "스크린 원단",
|
||||
unit: "㎡",
|
||||
required: 45,
|
||||
currentStock: 500,
|
||||
status: "sufficient",
|
||||
},
|
||||
{
|
||||
materialCode: "SCR-MAT-002",
|
||||
materialName: "앤드락",
|
||||
unit: "EA",
|
||||
required: 6,
|
||||
currentStock: 800,
|
||||
status: "sufficient",
|
||||
},
|
||||
{
|
||||
materialCode: "BND-MAT-001",
|
||||
materialName: "철판",
|
||||
unit: "KG",
|
||||
required: 90,
|
||||
currentStock: 2000,
|
||||
status: "sufficient",
|
||||
},
|
||||
{
|
||||
materialCode: "BND-MAT-002",
|
||||
materialName: "가이드레일",
|
||||
unit: "M",
|
||||
required: 18,
|
||||
currentStock: 300,
|
||||
status: "sufficient",
|
||||
},
|
||||
{
|
||||
materialCode: "BND-MAT-003",
|
||||
materialName: "케이스",
|
||||
unit: "EA",
|
||||
required: 3,
|
||||
currentStock: 100,
|
||||
status: "sufficient",
|
||||
},
|
||||
];
|
||||
|
||||
// 샘플 스크린 품목 상세
|
||||
const SAMPLE_SCREEN_ITEMS: ScreenItemDetail[] = [
|
||||
{
|
||||
no: 1,
|
||||
itemName: "스크린 셔터 (프리미엄)",
|
||||
location: "로비 I-01",
|
||||
openWidth: 4500,
|
||||
openHeight: 3500,
|
||||
productWidth: 4640,
|
||||
productHeight: 3900,
|
||||
guideRail: "백면형 120-70",
|
||||
shaft: '4"',
|
||||
capacity: "160kg",
|
||||
finish: "SUS마감",
|
||||
quantity: 1,
|
||||
},
|
||||
{
|
||||
no: 2,
|
||||
itemName: "스크린 셔터 (프리미엄)",
|
||||
location: "카페 I-02",
|
||||
openWidth: 4500,
|
||||
openHeight: 3500,
|
||||
productWidth: 4640,
|
||||
productHeight: 3900,
|
||||
guideRail: "백면형 120-70",
|
||||
shaft: '4"',
|
||||
capacity: "160kg",
|
||||
finish: "SUS마감",
|
||||
quantity: 1,
|
||||
},
|
||||
{
|
||||
no: 3,
|
||||
itemName: "스크린 셔터 (프리미엄)",
|
||||
location: "헬스장 I-03",
|
||||
openWidth: 4500,
|
||||
openHeight: 3500,
|
||||
productWidth: 4640,
|
||||
productHeight: 3900,
|
||||
guideRail: "백면형 120-70",
|
||||
shaft: '4"',
|
||||
capacity: "160kg",
|
||||
finish: "SUS마감",
|
||||
quantity: 1,
|
||||
},
|
||||
];
|
||||
|
||||
// 샘플 가이드레일 BOM
|
||||
const SAMPLE_GUIDE_RAIL_BOM: GuideRailBom[] = [
|
||||
{
|
||||
type: "백면형",
|
||||
spec: "120-70",
|
||||
code: "KSE01/KWE01",
|
||||
length: 3000,
|
||||
quantity: 6,
|
||||
},
|
||||
];
|
||||
|
||||
// 샘플 케이스(셔터박스) BOM
|
||||
const SAMPLE_CASE_BOM: CaseBom[] = [
|
||||
{ item: "케이스 본체", length: "L: 4000", quantity: 2 },
|
||||
{ item: "측면 덮개", length: "500-355", quantity: 6 },
|
||||
];
|
||||
|
||||
// 샘플 하단 마감재 BOM
|
||||
const SAMPLE_BOTTOM_FINISH_BOM: BottomFinishBom[] = [
|
||||
{ item: "하단마감재", spec: "50-40", length: "L: 4000", quantity: 3 },
|
||||
];
|
||||
|
||||
export default function ProductionOrderCreatePage() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const orderId = params.id as string;
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [orderInfo, setOrderInfo] = useState<OrderInfo | null>(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
// 우선순위 상태
|
||||
const [selectedPriority, setSelectedPriority] = useState<PriorityLevel>("normal");
|
||||
const [memo, setMemo] = useState("");
|
||||
|
||||
// 성공 다이얼로그 상태
|
||||
const [showSuccessDialog, setShowSuccessDialog] = useState(false);
|
||||
const [generatedOrderNumber, setGeneratedOrderNumber] = useState("");
|
||||
|
||||
// 데이터 로드
|
||||
useEffect(() => {
|
||||
setTimeout(() => {
|
||||
setOrderInfo(SAMPLE_ORDER_INFO);
|
||||
setLoading(false);
|
||||
}, 300);
|
||||
}, [orderId]);
|
||||
|
||||
const handleCancel = () => {
|
||||
router.push(`/sales/order-management-sales/${orderId}`);
|
||||
};
|
||||
|
||||
const handleBackToDetail = () => {
|
||||
router.push(`/sales/order-management-sales/${orderId}`);
|
||||
};
|
||||
|
||||
const handleConfirm = async () => {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
// TODO: API 호출
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
|
||||
// 생산지시번호 생성 (실제로는 API 응답에서 받아옴)
|
||||
const today = new Date();
|
||||
const dateStr = `${String(today.getFullYear()).slice(2)}${String(today.getMonth() + 1).padStart(2, "0")}${String(today.getDate()).padStart(2, "0")}`;
|
||||
const newOrderNumber = `PO-${orderInfo?.orderNumber.replace("KD-TS-", "KD-") || "KD-000000"}-${dateStr}`;
|
||||
|
||||
setGeneratedOrderNumber(newOrderNumber);
|
||||
setShowSuccessDialog(true);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSuccessDialogClose = () => {
|
||||
setShowSuccessDialog(false);
|
||||
// 생산지시 상세 페이지로 이동 (실제로는 API 응답에서 받은 생산지시 ID 사용)
|
||||
// 임시로 PO-002 사용 (샘플 데이터와 매칭)
|
||||
router.push("/sales/order-management-sales/production-orders/PO-002");
|
||||
};
|
||||
|
||||
// 선택된 우선순위 설정 가져오기
|
||||
const getSelectedPriorityConfig = () => {
|
||||
return PRIORITY_CONFIGS.find((p) => p.key === selectedPriority);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<PageLayout>
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
if (!orderInfo) {
|
||||
return (
|
||||
<PageLayout>
|
||||
<div className="text-center py-12">
|
||||
<p className="text-muted-foreground">수주 정보를 찾을 수 없습니다.</p>
|
||||
<Button variant="outline" onClick={handleCancel} className="mt-4">
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
돌아가기
|
||||
</Button>
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
const selectedConfig = getSelectedPriorityConfig();
|
||||
const workOrderCount = SAMPLE_WORK_ORDER_CARDS.length;
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
{/* 헤더 */}
|
||||
<PageHeader
|
||||
title={
|
||||
<div className="flex items-center gap-3">
|
||||
<span>생산지시 생성</span>
|
||||
<BadgeSm className="bg-gray-100 text-gray-700 border-gray-200">
|
||||
{workOrderCount}개 작업지시 생성 예정
|
||||
</BadgeSm>
|
||||
</div>
|
||||
}
|
||||
icon={Factory}
|
||||
actions={
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" onClick={handleCancel}>
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
취소
|
||||
</Button>
|
||||
<Button onClick={handleConfirm} disabled={isSubmitting}>
|
||||
<BarChart3 className="h-4 w-4 mr-2" />
|
||||
생산지시 확정
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* 수주 정보 */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">수주 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-6">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground mb-1">수주번호</p>
|
||||
<p className="font-semibold">{orderInfo.orderNumber}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground mb-1">거래처</p>
|
||||
<p className="font-medium">{orderInfo.client}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground mb-1">현장명</p>
|
||||
<p className="font-medium">{orderInfo.siteName}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground mb-1">납기일</p>
|
||||
<p className="font-semibold">{orderInfo.dueDate}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground mb-1">품목 수</p>
|
||||
<p className="font-medium">{orderInfo.itemCount}건</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground mb-1">총수량</p>
|
||||
<p className="font-medium">{orderInfo.totalQuantity}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground mb-1">신용등급</p>
|
||||
<BadgeSm className="bg-green-100 text-green-700 border-green-200">
|
||||
{orderInfo.creditGrade}
|
||||
</BadgeSm>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground mb-1">상태</p>
|
||||
<p className="font-semibold">{orderInfo.status}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 생산지시 옵션 */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">생산지시 옵션</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* 우선순위 (영업) */}
|
||||
<div>
|
||||
<p className="text-sm font-medium mb-3">우선순위 (영업)</p>
|
||||
<div className="flex gap-2 mb-4">
|
||||
{PRIORITY_CONFIGS.map((config) => {
|
||||
const colors = PRIORITY_COLORS[config.key];
|
||||
return (
|
||||
<button
|
||||
key={config.key}
|
||||
onClick={() => setSelectedPriority(config.key)}
|
||||
className={cn(
|
||||
"px-4 py-2 rounded-md text-sm font-medium transition-colors",
|
||||
selectedPriority === config.key
|
||||
? colors.buttonActive
|
||||
: colors.button
|
||||
)}
|
||||
>
|
||||
{config.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 우선순위 테이블 */}
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[200px]">생산지시 (영업)</TableHead>
|
||||
<TableHead className="w-[200px]">작업지시 기본값 (공장)</TableHead>
|
||||
<TableHead>비고 (현장의 여유 공간)</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{PRIORITY_CONFIGS.map((config) => {
|
||||
const colors = PRIORITY_COLORS[config.key];
|
||||
const isSelected = selectedPriority === config.key;
|
||||
return (
|
||||
<TableRow
|
||||
key={config.key}
|
||||
className={cn(isSelected && colors.bg)}
|
||||
>
|
||||
<TableCell className={cn("font-medium", isSelected && colors.text)}>
|
||||
{config.productionOrder}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<BadgeSm className={cn(colors.bg, colors.text, colors.border)}>
|
||||
{config.workOrderPriority}
|
||||
</BadgeSm>
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">{config.note}</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* 선택된 설정 표시 */}
|
||||
{selectedConfig && (
|
||||
<div className="flex items-center gap-2 mt-4">
|
||||
<span className="text-sm text-muted-foreground">선택된 설정:</span>
|
||||
<BadgeSm className={cn(
|
||||
PRIORITY_COLORS[selectedPriority].bg,
|
||||
PRIORITY_COLORS[selectedPriority].text,
|
||||
PRIORITY_COLORS[selectedPriority].border
|
||||
)}>
|
||||
생산지시: {selectedConfig.productionOrder}
|
||||
</BadgeSm>
|
||||
<span className="text-muted-foreground">→</span>
|
||||
<BadgeSm className={cn(
|
||||
PRIORITY_COLORS[selectedPriority].bg,
|
||||
PRIORITY_COLORS[selectedPriority].text,
|
||||
PRIORITY_COLORS[selectedPriority].border
|
||||
)}>
|
||||
작업지시: {selectedConfig.workOrderPriority}
|
||||
</BadgeSm>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 메모 */}
|
||||
<div>
|
||||
<p className="text-sm font-medium mb-2">메모</p>
|
||||
<Textarea
|
||||
value={memo}
|
||||
onChange={(e) => setMemo(e.target.value)}
|
||||
placeholder="생산지시 관련 특이사항..."
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 생성될 작업지시 */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">생성될 작업지시 ({workOrderCount}건)</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{SAMPLE_WORK_ORDER_CARDS.map((card) => (
|
||||
<div
|
||||
key={card.id}
|
||||
className={cn(
|
||||
"border rounded-lg p-4",
|
||||
card.type === "스크린" ? "bg-blue-50/50 border-blue-200" : "bg-orange-50/50 border-orange-200"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<BadgeSm className={cn(
|
||||
card.type === "스크린"
|
||||
? "bg-blue-100 text-blue-700 border-blue-200"
|
||||
: "bg-orange-100 text-orange-700 border-orange-200"
|
||||
)}>
|
||||
{card.type}
|
||||
</BadgeSm>
|
||||
<span className="font-mono text-sm font-medium">{card.orderNumber}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4 mb-3">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">품목 수</p>
|
||||
<p className="font-medium">{card.itemCount}건</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">총 수량</p>
|
||||
<p className="font-medium">{card.totalQuantity}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground mb-2">공정 순서</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{card.processes.map((process, idx) => (
|
||||
<BadgeSm
|
||||
key={idx}
|
||||
className="bg-gray-50 text-gray-600 border-gray-200"
|
||||
>
|
||||
{process}
|
||||
</BadgeSm>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 자재 소요량 및 재고 현황 */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">자재 소요량 및 재고 현황</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>자재코드</TableHead>
|
||||
<TableHead>자재명</TableHead>
|
||||
<TableHead className="text-center">단위</TableHead>
|
||||
<TableHead className="text-right">소요량</TableHead>
|
||||
<TableHead className="text-right">현재고</TableHead>
|
||||
<TableHead className="text-center">상태</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{SAMPLE_MATERIALS.map((item) => (
|
||||
<TableRow key={item.materialCode}>
|
||||
<TableCell>
|
||||
<code className="text-xs bg-gray-100 px-1.5 py-0.5 rounded">
|
||||
{item.materialCode}
|
||||
</code>
|
||||
</TableCell>
|
||||
<TableCell>{item.materialName}</TableCell>
|
||||
<TableCell className="text-center">{item.unit}</TableCell>
|
||||
<TableCell className="text-right font-medium">{item.required}</TableCell>
|
||||
<TableCell className="text-right">{item.currentStock.toLocaleString()}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<BadgeSm
|
||||
className={cn(
|
||||
item.status === "sufficient"
|
||||
? "bg-green-100 text-green-700 border-green-200"
|
||||
: "bg-red-100 text-red-700 border-red-200"
|
||||
)}
|
||||
>
|
||||
{item.status === "sufficient" ? "충분" : "부족"}
|
||||
</BadgeSm>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 스크린 품목 상세 */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">스크린 품목 상세 ({SAMPLE_SCREEN_ITEMS.length}건)</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="border rounded-lg overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[50px] text-center">No</TableHead>
|
||||
<TableHead>품목명</TableHead>
|
||||
<TableHead>도면위치</TableHead>
|
||||
<TableHead className="text-right">개구폭</TableHead>
|
||||
<TableHead className="text-right">개구높이</TableHead>
|
||||
<TableHead className="text-right">제작폭</TableHead>
|
||||
<TableHead className="text-right">제작높이</TableHead>
|
||||
<TableHead>가이드레일</TableHead>
|
||||
<TableHead className="text-center">샤프트</TableHead>
|
||||
<TableHead className="text-center">용량</TableHead>
|
||||
<TableHead>마감</TableHead>
|
||||
<TableHead className="text-center">수량</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{SAMPLE_SCREEN_ITEMS.map((item) => (
|
||||
<TableRow key={item.no}>
|
||||
<TableCell className="text-center font-medium">
|
||||
{String(item.no).padStart(2, "0")}
|
||||
</TableCell>
|
||||
<TableCell>{item.itemName}</TableCell>
|
||||
<TableCell>{item.location}</TableCell>
|
||||
<TableCell className="text-right">{item.openWidth.toLocaleString()}</TableCell>
|
||||
<TableCell className="text-right">{item.openHeight.toLocaleString()}</TableCell>
|
||||
<TableCell className="text-right">{item.productWidth.toLocaleString()}</TableCell>
|
||||
<TableCell className="text-right">{item.productHeight.toLocaleString()}</TableCell>
|
||||
<TableCell>{item.guideRail}</TableCell>
|
||||
<TableCell className="text-center">{item.shaft}</TableCell>
|
||||
<TableCell className="text-center">{item.capacity}</TableCell>
|
||||
<TableCell>{item.finish}</TableCell>
|
||||
<TableCell className="text-center">{item.quantity}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 모터/전장품 사양 */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">모터/전장품 사양</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground mb-1">모터 사양 (380V)</p>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="font-medium">KD-150K</span>
|
||||
<span className="text-muted-foreground">3대</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground mb-1">모터 브라켓</p>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="font-medium">380-180 [2-4"]</span>
|
||||
<span className="text-muted-foreground">3개</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 절곡물 BOM */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">절곡물 BOM</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{/* 가이드레일 */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium mb-2">가이드레일</h4>
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>형태</TableHead>
|
||||
<TableHead>규격</TableHead>
|
||||
<TableHead>코드</TableHead>
|
||||
<TableHead className="text-right">길이</TableHead>
|
||||
<TableHead className="text-center">수량</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{SAMPLE_GUIDE_RAIL_BOM.map((item, index) => (
|
||||
<TableRow key={index}>
|
||||
<TableCell>{item.type}</TableCell>
|
||||
<TableCell>{item.spec}</TableCell>
|
||||
<TableCell>{item.code}</TableCell>
|
||||
<TableCell className="text-right">{item.length.toLocaleString()}</TableCell>
|
||||
<TableCell className="text-center">{item.quantity}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 케이스(셔터박스) */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium mb-2">케이스(셔터박스) - 메인 규격: 500-330</h4>
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>품목</TableHead>
|
||||
<TableHead>길이</TableHead>
|
||||
<TableHead className="text-center">수량</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{SAMPLE_CASE_BOM.map((item, index) => (
|
||||
<TableRow key={index}>
|
||||
<TableCell>{item.item}</TableCell>
|
||||
<TableCell>{item.length}</TableCell>
|
||||
<TableCell className="text-center">{item.quantity}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 하단 마감재 */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium mb-2">하단 마감재</h4>
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>품목</TableHead>
|
||||
<TableHead>규격</TableHead>
|
||||
<TableHead>길이</TableHead>
|
||||
<TableHead className="text-center">수량</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{SAMPLE_BOTTOM_FINISH_BOM.map((item, index) => (
|
||||
<TableRow key={index}>
|
||||
<TableCell>{item.item}</TableCell>
|
||||
<TableCell>{item.spec}</TableCell>
|
||||
<TableCell>{item.length}</TableCell>
|
||||
<TableCell className="text-center">{item.quantity}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 하단 버튼 영역 */}
|
||||
<div className="sticky bottom-0 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 border-t pt-4 pb-4 -mx-3 md:-mx-6 px-3 md:px-6 mt-6">
|
||||
<div className="flex items-center justify-between">
|
||||
{/* 우선순위 뱃지 */}
|
||||
<div className="flex items-center gap-4">
|
||||
{selectedConfig && (
|
||||
<BadgeSm className={cn(
|
||||
PRIORITY_COLORS[selectedPriority].buttonActive,
|
||||
"border-0"
|
||||
)}>
|
||||
우선순위: {selectedConfig.productionOrder}
|
||||
</BadgeSm>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" onClick={handleBackToDetail}>
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
수주상세로
|
||||
</Button>
|
||||
<Button onClick={handleConfirm} disabled={isSubmitting}>
|
||||
<BarChart3 className="h-4 w-4 mr-2" />
|
||||
생산지시 확정 ({SAMPLE_SCREEN_ITEMS.length}건)
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 생산지시 생성 성공 다이얼로그 */}
|
||||
<AlertDialog open={showSuccessDialog} onOpenChange={setShowSuccessDialog}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle className="flex items-center gap-2">
|
||||
<CheckCircle2 className="h-5 w-5 text-green-600" />
|
||||
생산지시가 생성되었습니다.
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription asChild>
|
||||
<div className="space-y-4 pt-2">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">생산지시번호:</p>
|
||||
<p className="font-mono font-semibold text-foreground">{generatedOrderNumber}</p>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
생산관리 > 생산지시 관리에서 작업지시서를 생성하세요.
|
||||
</p>
|
||||
</div>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogAction onClick={handleSuccessDialogClose}>
|
||||
확인
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 수주 등록 페이지
|
||||
*/
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { OrderRegistration, OrderFormData } from "@/components/orders";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export default function OrderNewPage() {
|
||||
const router = useRouter();
|
||||
|
||||
const handleBack = () => {
|
||||
router.push("/sales/order-management-sales");
|
||||
};
|
||||
|
||||
const handleSave = async (formData: OrderFormData) => {
|
||||
// TODO: API 연동
|
||||
console.log("수주 등록 데이터:", formData);
|
||||
|
||||
// 임시: 성공 시뮬레이션
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
|
||||
toast.success("수주가 등록되었습니다.");
|
||||
router.push("/sales/order-management-sales");
|
||||
};
|
||||
|
||||
return <OrderRegistration onBack={handleBack} onSave={handleSave} />;
|
||||
}
|
||||
@@ -0,0 +1,765 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 수주관리 - IntegratedListTemplateV2 적용
|
||||
*
|
||||
* 수주 관리 페이지
|
||||
* - 상단 통계 카드: 이번 달 수주, 분할 대기, 생산지시 대기, 출하 대기
|
||||
* - 필터 탭: 전체, 수주등록, 수주확정, 생산지시완료, 미수
|
||||
* - 완전한 반응형 지원
|
||||
*/
|
||||
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
FileText,
|
||||
Edit,
|
||||
Trash2,
|
||||
DollarSign,
|
||||
SplitSquareVertical,
|
||||
ClipboardList,
|
||||
Truck,
|
||||
Eye,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { BadgeSm } from "@/components/atoms/BadgeSm";
|
||||
import {
|
||||
IntegratedListTemplateV2,
|
||||
TabOption,
|
||||
TableColumn,
|
||||
} from "@/components/templates/IntegratedListTemplateV2";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
TableHead,
|
||||
TableBody,
|
||||
TableCell,
|
||||
} from "@/components/ui/table";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { formatAmount, formatAmountManwon } from "@/utils/formatAmount";
|
||||
import { ListMobileCard, InfoField } from "@/components/organisms/ListMobileCard";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
|
||||
// 수주 상태 타입
|
||||
type OrderStatus =
|
||||
| "order_registered" // 수주등록
|
||||
| "order_confirmed" // 수주확정
|
||||
| "production_ordered" // 생산지시완료
|
||||
| "in_production" // 생산중
|
||||
| "rework" // 재작업중
|
||||
| "work_completed" // 작업완료
|
||||
| "shipped" // 출하완료
|
||||
| "cancelled"; // 취소
|
||||
|
||||
// 수주 타입
|
||||
interface Order {
|
||||
id: string;
|
||||
lotNumber: string; // 로트번호 KD-TS-XXXXXX-XX
|
||||
quoteNumber: string; // 견적번호 KD-PR-XXXXXX-XX
|
||||
orderDate: string; // 수주일자
|
||||
client: string; // 발주처
|
||||
siteName: string; // 현장명
|
||||
status: OrderStatus;
|
||||
expectedShipDate?: string; // 출고예정일
|
||||
deliveryMethod?: string; // 배송방식
|
||||
amount: number; // 금액
|
||||
itemCount: number; // 품목 수
|
||||
hasReceivable?: boolean; // 미수 여부
|
||||
}
|
||||
|
||||
// 샘플 수주 데이터
|
||||
const SAMPLE_ORDERS: Order[] = [
|
||||
{
|
||||
id: "ORD-001",
|
||||
lotNumber: "KD-TS-251217-01",
|
||||
quoteNumber: "KD-PR-251210-01",
|
||||
orderDate: "2024-12-17",
|
||||
client: "태영건설(주)",
|
||||
siteName: "데시앙 동탄 파크뷰",
|
||||
status: "order_confirmed",
|
||||
expectedShipDate: "2025-01-15",
|
||||
deliveryMethod: "직접배차",
|
||||
amount: 38800000,
|
||||
itemCount: 5,
|
||||
hasReceivable: false,
|
||||
},
|
||||
{
|
||||
id: "ORD-002",
|
||||
lotNumber: "KD-TS-251217-02",
|
||||
quoteNumber: "KD-PR-251211-02",
|
||||
orderDate: "2024-12-17",
|
||||
client: "현대건설(주)",
|
||||
siteName: "힐스테이트 판교역",
|
||||
status: "in_production",
|
||||
expectedShipDate: "2025-01-20",
|
||||
deliveryMethod: "상차",
|
||||
amount: 52500000,
|
||||
itemCount: 8,
|
||||
hasReceivable: false,
|
||||
},
|
||||
{
|
||||
id: "ORD-003",
|
||||
lotNumber: "KD-TS-251216-01",
|
||||
quoteNumber: "KD-PR-251208-03",
|
||||
orderDate: "2024-12-16",
|
||||
client: "GS건설(주)",
|
||||
siteName: "자이 강남센터",
|
||||
status: "production_ordered",
|
||||
expectedShipDate: "2025-01-10",
|
||||
deliveryMethod: "직접배차",
|
||||
amount: 45000000,
|
||||
itemCount: 6,
|
||||
hasReceivable: false,
|
||||
},
|
||||
{
|
||||
id: "ORD-004",
|
||||
lotNumber: "KD-TS-251215-01",
|
||||
quoteNumber: "KD-PR-251205-04",
|
||||
orderDate: "2024-12-15",
|
||||
client: "대우건설(주)",
|
||||
siteName: "푸르지오 송도",
|
||||
status: "shipped",
|
||||
expectedShipDate: "2024-12-20",
|
||||
deliveryMethod: "상차",
|
||||
amount: 28900000,
|
||||
itemCount: 4,
|
||||
hasReceivable: true,
|
||||
},
|
||||
{
|
||||
id: "ORD-005",
|
||||
lotNumber: "KD-TS-251214-01",
|
||||
quoteNumber: "KD-PR-251201-05",
|
||||
orderDate: "2024-12-14",
|
||||
client: "포스코건설",
|
||||
siteName: "더샵 분당센트럴",
|
||||
status: "rework",
|
||||
expectedShipDate: "2025-01-25",
|
||||
deliveryMethod: "직접배차",
|
||||
amount: 62000000,
|
||||
itemCount: 10,
|
||||
hasReceivable: false,
|
||||
},
|
||||
{
|
||||
id: "ORD-006",
|
||||
lotNumber: "KD-TS-251213-01",
|
||||
quoteNumber: "KD-PR-251128-06",
|
||||
orderDate: "2024-12-13",
|
||||
client: "롯데건설(주)",
|
||||
siteName: "캐슬 잠실파크",
|
||||
status: "work_completed",
|
||||
expectedShipDate: "2024-12-25",
|
||||
deliveryMethod: "직접배차",
|
||||
amount: 35500000,
|
||||
itemCount: 5,
|
||||
hasReceivable: false,
|
||||
},
|
||||
{
|
||||
id: "ORD-007",
|
||||
lotNumber: "KD-TS-251212-01",
|
||||
quoteNumber: "KD-PR-251125-07",
|
||||
orderDate: "2024-12-12",
|
||||
client: "삼성물산(주)",
|
||||
siteName: "래미안 서초",
|
||||
status: "order_registered",
|
||||
expectedShipDate: undefined,
|
||||
deliveryMethod: undefined,
|
||||
amount: 48000000,
|
||||
itemCount: 7,
|
||||
hasReceivable: false,
|
||||
},
|
||||
{
|
||||
id: "ORD-008",
|
||||
lotNumber: "KD-TS-251211-01",
|
||||
quoteNumber: "KD-PR-251120-08",
|
||||
orderDate: "2024-12-11",
|
||||
client: "SK에코플랜트",
|
||||
siteName: "SK VIEW 일산",
|
||||
status: "shipped",
|
||||
expectedShipDate: "2024-12-18",
|
||||
deliveryMethod: "상차",
|
||||
amount: 31200000,
|
||||
itemCount: 4,
|
||||
hasReceivable: true,
|
||||
},
|
||||
];
|
||||
|
||||
// 상태 뱃지 헬퍼 함수
|
||||
function getOrderStatusBadge(status: OrderStatus) {
|
||||
const statusConfig: Record<OrderStatus, { label: string; variant: string; className: string }> = {
|
||||
order_registered: { label: "수주등록", variant: "default", className: "bg-gray-100 text-gray-700 border-gray-200" },
|
||||
order_confirmed: { label: "수주확정", variant: "default", className: "bg-gray-100 text-gray-700 border-gray-200" },
|
||||
production_ordered: { label: "생산지시완료", variant: "default", className: "bg-blue-100 text-blue-700 border-blue-200" },
|
||||
in_production: { label: "생산중", variant: "default", className: "bg-green-100 text-green-700 border-green-200" },
|
||||
rework: { label: "재작업중", variant: "default", className: "bg-orange-100 text-orange-700 border-orange-200" },
|
||||
work_completed: { label: "작업완료", variant: "default", className: "bg-blue-600 text-white border-blue-600" },
|
||||
shipped: { label: "출하완료", variant: "default", className: "bg-gray-500 text-white border-gray-500" },
|
||||
cancelled: { label: "취소", variant: "default", className: "bg-red-100 text-red-700 border-red-200" },
|
||||
};
|
||||
|
||||
const config = statusConfig[status];
|
||||
return (
|
||||
<BadgeSm className={config.className}>
|
||||
{config.label}
|
||||
</BadgeSm>
|
||||
);
|
||||
}
|
||||
|
||||
export default function OrderManagementSalesPage() {
|
||||
const router = useRouter();
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [filterType, setFilterType] = useState("all");
|
||||
const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set());
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const itemsPerPage = 20;
|
||||
|
||||
// 취소 확인 다이얼로그 state
|
||||
const [isCancelDialogOpen, setIsCancelDialogOpen] = useState(false);
|
||||
const [cancelTargetId, setCancelTargetId] = useState<string | null>(null);
|
||||
|
||||
// 삭제 확인 다이얼로그 state (다중 선택 삭제 지원)
|
||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||
const [deleteTargetIds, setDeleteTargetIds] = useState<string[]>([]);
|
||||
|
||||
// 모바일 인피니티 스크롤 state
|
||||
const [mobileDisplayCount, setMobileDisplayCount] = useState(20);
|
||||
const sentinelRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// 로컬 데이터 state (실제 구현에서는 API 연동)
|
||||
const [orders, setOrders] = useState<Order[]>(SAMPLE_ORDERS);
|
||||
|
||||
// 필터링 및 정렬
|
||||
const filteredOrders = orders
|
||||
.filter((order) => {
|
||||
const searchLower = searchTerm.toLowerCase();
|
||||
const matchesSearch =
|
||||
!searchTerm ||
|
||||
order.lotNumber.toLowerCase().includes(searchLower) ||
|
||||
order.quoteNumber.toLowerCase().includes(searchLower) ||
|
||||
order.client.toLowerCase().includes(searchLower) ||
|
||||
order.siteName.toLowerCase().includes(searchLower);
|
||||
|
||||
let matchesFilter = true;
|
||||
if (filterType === "registered") {
|
||||
matchesFilter = order.status === "order_registered";
|
||||
} else if (filterType === "confirmed") {
|
||||
matchesFilter = order.status === "order_confirmed";
|
||||
} else if (filterType === "production_ordered") {
|
||||
matchesFilter = order.status === "production_ordered";
|
||||
} else if (filterType === "receivable") {
|
||||
matchesFilter = order.hasReceivable === true;
|
||||
}
|
||||
|
||||
return matchesSearch && matchesFilter;
|
||||
})
|
||||
.sort((a, b) => {
|
||||
return new Date(b.orderDate).getTime() - new Date(a.orderDate).getTime();
|
||||
});
|
||||
|
||||
// 페이지네이션
|
||||
const totalPages = Math.ceil(filteredOrders.length / itemsPerPage);
|
||||
const paginatedOrders = filteredOrders.slice(
|
||||
(currentPage - 1) * itemsPerPage,
|
||||
currentPage * itemsPerPage
|
||||
);
|
||||
|
||||
// 모바일용 인피니티 스크롤 데이터
|
||||
const mobileOrders = filteredOrders.slice(0, mobileDisplayCount);
|
||||
|
||||
// Intersection Observer를 이용한 인피니티 스크롤
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
if (window.innerWidth >= 1280) return;
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (
|
||||
entries[0].isIntersecting &&
|
||||
mobileDisplayCount < filteredOrders.length
|
||||
) {
|
||||
setMobileDisplayCount((prev) =>
|
||||
Math.min(prev + 20, filteredOrders.length)
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
threshold: 0.1,
|
||||
rootMargin: "100px",
|
||||
}
|
||||
);
|
||||
|
||||
if (sentinelRef.current) {
|
||||
observer.observe(sentinelRef.current);
|
||||
}
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
};
|
||||
}, [mobileDisplayCount, filteredOrders.length]);
|
||||
|
||||
// 탭이나 검색어 변경 시 모바일 표시 개수 초기화
|
||||
useEffect(() => {
|
||||
setMobileDisplayCount(20);
|
||||
}, [searchTerm, filterType]);
|
||||
|
||||
// 통계 계산
|
||||
const now = new Date();
|
||||
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
|
||||
// 이번 달 수주 금액
|
||||
const thisMonthOrders = orders.filter(
|
||||
(o) => new Date(o.orderDate) >= startOfMonth
|
||||
);
|
||||
const thisMonthAmount = thisMonthOrders.reduce((sum, o) => sum + o.amount, 0);
|
||||
|
||||
// 분할 대기 (예시: 수주확정 상태)
|
||||
const splitPendingCount = orders.filter((o) => o.status === "order_confirmed").length;
|
||||
|
||||
// 생산지시 대기 (수주확정 상태 중 생산지시 안된 것)
|
||||
const productionPendingCount = orders.filter(
|
||||
(o) => o.status === "order_confirmed" || o.status === "order_registered"
|
||||
).length;
|
||||
|
||||
// 출하 대기 (작업완료 상태)
|
||||
const shipPendingCount = orders.filter((o) => o.status === "work_completed").length;
|
||||
|
||||
const stats = [
|
||||
{
|
||||
label: "이번 달 수주",
|
||||
value: formatAmountManwon(thisMonthAmount),
|
||||
icon: DollarSign,
|
||||
iconColor: "text-blue-600",
|
||||
},
|
||||
{
|
||||
label: "분할 대기",
|
||||
value: `${splitPendingCount}건`,
|
||||
icon: SplitSquareVertical,
|
||||
iconColor: "text-orange-600",
|
||||
},
|
||||
{
|
||||
label: "생산지시 대기",
|
||||
value: `${productionPendingCount}건`,
|
||||
icon: ClipboardList,
|
||||
iconColor: "text-green-600",
|
||||
},
|
||||
{
|
||||
label: "출하 대기",
|
||||
value: `${shipPendingCount}건`,
|
||||
icon: Truck,
|
||||
iconColor: "text-purple-600",
|
||||
},
|
||||
];
|
||||
|
||||
// 핸들러
|
||||
const handleView = (order: Order) => {
|
||||
router.push(`/sales/order-management-sales/${order.id}`);
|
||||
};
|
||||
|
||||
const handleEdit = (order: Order) => {
|
||||
router.push(`/sales/order-management-sales/${order.id}/edit`);
|
||||
};
|
||||
|
||||
const handleCancel = (orderId: string) => {
|
||||
setCancelTargetId(orderId);
|
||||
setIsCancelDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleConfirmCancel = () => {
|
||||
if (cancelTargetId) {
|
||||
const order = orders.find((o) => o.id === cancelTargetId);
|
||||
setOrders(
|
||||
orders.map((o) =>
|
||||
o.id === cancelTargetId ? { ...o, status: "cancelled" as OrderStatus } : o
|
||||
)
|
||||
);
|
||||
toast.success(
|
||||
`수주가 취소되었습니다${order ? `: ${order.lotNumber}` : ""}`
|
||||
);
|
||||
setIsCancelDialogOpen(false);
|
||||
setCancelTargetId(null);
|
||||
}
|
||||
};
|
||||
|
||||
// 개별 삭제 (row에서 휴지통 아이콘 클릭)
|
||||
const handleDelete = (orderId: string) => {
|
||||
setDeleteTargetIds([orderId]);
|
||||
setIsDeleteDialogOpen(true);
|
||||
};
|
||||
|
||||
// 다중 선택 삭제 (IntegratedListTemplateV2에서 확인 후 호출됨)
|
||||
// 템플릿 내부에서 이미 확인 팝업을 처리하므로 바로 삭제 실행
|
||||
const handleBulkDelete = () => {
|
||||
const selectedIds = Array.from(selectedItems);
|
||||
if (selectedIds.length > 0) {
|
||||
setOrders(orders.filter((o) => !selectedIds.includes(o.id)));
|
||||
setSelectedItems(new Set());
|
||||
toast.success(`${selectedIds.length}개의 수주가 삭제되었습니다.`);
|
||||
}
|
||||
};
|
||||
|
||||
// 삭제 확정 (단일/다중 모두 처리)
|
||||
const handleConfirmDelete = () => {
|
||||
if (deleteTargetIds.length > 0) {
|
||||
const count = deleteTargetIds.length;
|
||||
setOrders(orders.filter((o) => !deleteTargetIds.includes(o.id)));
|
||||
// 선택 상태 초기화
|
||||
setSelectedItems(new Set());
|
||||
toast.success(`${count}개의 수주가 삭제되었습니다.`);
|
||||
setIsDeleteDialogOpen(false);
|
||||
setDeleteTargetIds([]);
|
||||
}
|
||||
};
|
||||
|
||||
// 체크박스 선택
|
||||
const toggleSelection = (id: string) => {
|
||||
const newSelection = new Set(selectedItems);
|
||||
if (newSelection.has(id)) {
|
||||
newSelection.delete(id);
|
||||
} else {
|
||||
newSelection.add(id);
|
||||
}
|
||||
setSelectedItems(newSelection);
|
||||
};
|
||||
|
||||
const toggleSelectAll = () => {
|
||||
if (
|
||||
selectedItems.size === paginatedOrders.length &&
|
||||
paginatedOrders.length > 0
|
||||
) {
|
||||
setSelectedItems(new Set());
|
||||
} else {
|
||||
setSelectedItems(new Set(paginatedOrders.map((o) => o.id)));
|
||||
}
|
||||
};
|
||||
|
||||
// 탭 구성
|
||||
const tabs: TabOption[] = [
|
||||
{
|
||||
value: "all",
|
||||
label: "전체",
|
||||
count: orders.length,
|
||||
color: "blue",
|
||||
},
|
||||
{
|
||||
value: "registered",
|
||||
label: "수주등록",
|
||||
count: orders.filter((o) => o.status === "order_registered").length,
|
||||
color: "gray",
|
||||
},
|
||||
{
|
||||
value: "confirmed",
|
||||
label: "수주확정",
|
||||
count: orders.filter((o) => o.status === "order_confirmed").length,
|
||||
color: "gray",
|
||||
},
|
||||
{
|
||||
value: "production_ordered",
|
||||
label: "생산지시완료",
|
||||
count: orders.filter((o) => o.status === "production_ordered").length,
|
||||
color: "blue",
|
||||
},
|
||||
{
|
||||
value: "receivable",
|
||||
label: "미수",
|
||||
count: orders.filter((o) => o.hasReceivable).length,
|
||||
color: "red",
|
||||
},
|
||||
];
|
||||
|
||||
// 테이블 컬럼 정의
|
||||
const tableColumns: TableColumn[] = [
|
||||
{ key: "rowNumber", label: "번호", className: "px-4" },
|
||||
{ key: "lotNumber", label: "로트번호", className: "px-4" },
|
||||
{ key: "quoteNumber", label: "견적번호", className: "px-4" },
|
||||
{ key: "client", label: "발주처", className: "px-4" },
|
||||
{ key: "siteName", label: "현장명", className: "px-4" },
|
||||
{ key: "status", label: "상태", className: "px-4" },
|
||||
{ key: "expectedShipDate", label: "출고예정일", className: "px-4" },
|
||||
{ key: "deliveryMethod", label: "배송방식", className: "px-4" },
|
||||
{ key: "actions", label: "작업", className: "px-4" },
|
||||
];
|
||||
|
||||
// 테이블 행 렌더링
|
||||
const renderTableRow = (
|
||||
order: Order,
|
||||
index: number,
|
||||
globalIndex: number
|
||||
) => {
|
||||
const itemId = order.id;
|
||||
const isSelected = selectedItems.has(itemId);
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
key={order.id}
|
||||
className={`cursor-pointer hover:bg-muted/50 ${isSelected ? "bg-blue-50" : ""}`}
|
||||
onClick={() => handleView(order)}
|
||||
>
|
||||
<TableCell onClick={(e) => e.stopPropagation()} className="text-center">
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={() => toggleSelection(itemId)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>{globalIndex}</TableCell>
|
||||
<TableCell>
|
||||
<code className="text-xs bg-gray-100 text-gray-700 px-2 py-1 rounded font-mono">
|
||||
{order.lotNumber}
|
||||
</code>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<code className="text-xs bg-gray-100 text-gray-700 px-2 py-1 rounded font-mono">
|
||||
{order.quoteNumber}
|
||||
</code>
|
||||
</TableCell>
|
||||
<TableCell>{order.client}</TableCell>
|
||||
<TableCell>{order.siteName}</TableCell>
|
||||
<TableCell>{getOrderStatusBadge(order.status)}</TableCell>
|
||||
<TableCell>{order.expectedShipDate || "-"}</TableCell>
|
||||
<TableCell>{order.deliveryMethod || "-"}</TableCell>
|
||||
<TableCell onClick={(e) => e.stopPropagation()}>
|
||||
{isSelected && (
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleView(order)}
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleEdit(order)}
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
{/* 삭제 버튼 - shipped, cancelled 제외 모든 상태에서 표시 */}
|
||||
{order.status !== "shipped" && order.status !== "cancelled" && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(order.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-red-500" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
};
|
||||
|
||||
// 모바일 카드 렌더링
|
||||
const renderMobileCard = (
|
||||
order: Order,
|
||||
index: number,
|
||||
globalIndex: number,
|
||||
isSelected: boolean,
|
||||
onToggle: () => void
|
||||
) => {
|
||||
return (
|
||||
<ListMobileCard
|
||||
key={order.id}
|
||||
id={order.id}
|
||||
isSelected={isSelected}
|
||||
onToggleSelection={onToggle}
|
||||
onCardClick={() => handleView(order)}
|
||||
headerBadges={
|
||||
<>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="bg-gray-100 text-gray-700 font-mono text-xs"
|
||||
>
|
||||
#{globalIndex}
|
||||
</Badge>
|
||||
<code className="inline-block text-xs bg-gray-100 text-gray-700 px-2.5 py-0.5 rounded-md font-mono whitespace-nowrap">
|
||||
{order.lotNumber}
|
||||
</code>
|
||||
</>
|
||||
}
|
||||
title={order.client}
|
||||
statusBadge={getOrderStatusBadge(order.status)}
|
||||
infoGrid={
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-3">
|
||||
<InfoField label="현장명" value={order.siteName} />
|
||||
<InfoField label="견적번호" value={order.quoteNumber} />
|
||||
<InfoField label="출고예정일" value={order.expectedShipDate || "-"} />
|
||||
<InfoField label="배송방식" value={order.deliveryMethod || "-"} />
|
||||
<InfoField
|
||||
label="금액"
|
||||
value={formatAmount(order.amount)}
|
||||
valueClassName="text-green-600"
|
||||
/>
|
||||
<InfoField label="품목 수" value={`${order.itemCount}개`} />
|
||||
</div>
|
||||
}
|
||||
actions={
|
||||
isSelected ? (
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="default"
|
||||
className="flex-1 min-w-[100px] h-11"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleView(order);
|
||||
}}
|
||||
>
|
||||
<Eye className="h-4 w-4 mr-2" />
|
||||
상세
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
size="default"
|
||||
className="flex-1 min-w-[100px] h-11"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleEdit(order);
|
||||
}}
|
||||
>
|
||||
<Edit className="h-4 w-4 mr-2" />
|
||||
수정
|
||||
</Button>
|
||||
{/* 삭제 버튼 - shipped, cancelled 제외 모든 상태에서 표시 */}
|
||||
{order.status !== "shipped" && order.status !== "cancelled" && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="default"
|
||||
className="flex-1 min-w-[100px] h-11 border-red-200 text-red-600 hover:border-red-300"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDelete(order.id);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
삭제
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<IntegratedListTemplateV2
|
||||
title="수주 목록"
|
||||
description="수주 관리 및 생산지시 연동"
|
||||
icon={FileText}
|
||||
headerActions={
|
||||
<Button onClick={() => router.push("/sales/order-management-sales/new")}>
|
||||
<FileText className="w-4 h-4 mr-2" />
|
||||
수주 등록
|
||||
</Button>
|
||||
}
|
||||
stats={stats}
|
||||
searchValue={searchTerm}
|
||||
onSearchChange={setSearchTerm}
|
||||
searchPlaceholder="로트번호, 견적번호, 발주처, 현장명 검색..."
|
||||
tabs={tabs}
|
||||
activeTab={filterType}
|
||||
onTabChange={(value) => {
|
||||
setFilterType(value);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
tableColumns={tableColumns}
|
||||
tableTitle={`${tabs.find((t) => t.value === filterType)?.label || "전체"} (${filteredOrders.length}개)`}
|
||||
data={paginatedOrders}
|
||||
totalCount={filteredOrders.length}
|
||||
allData={mobileOrders}
|
||||
mobileDisplayCount={mobileDisplayCount}
|
||||
infinityScrollSentinelRef={sentinelRef}
|
||||
selectedItems={selectedItems}
|
||||
onToggleSelection={toggleSelection}
|
||||
onToggleSelectAll={toggleSelectAll}
|
||||
getItemId={(order) => order.id}
|
||||
onBulkDelete={handleBulkDelete}
|
||||
renderTableRow={renderTableRow}
|
||||
renderMobileCard={renderMobileCard}
|
||||
pagination={{
|
||||
currentPage,
|
||||
totalPages,
|
||||
totalItems: filteredOrders.length,
|
||||
itemsPerPage,
|
||||
onPageChange: setCurrentPage,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 수주 취소 확인 다이얼로그 */}
|
||||
<AlertDialog open={isCancelDialogOpen} onOpenChange={setIsCancelDialogOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>수주 취소 확인</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{cancelTargetId
|
||||
? `수주번호: ${orders.find((o) => o.id === cancelTargetId)?.lotNumber || cancelTargetId}`
|
||||
: ""}
|
||||
<br />
|
||||
이 수주를 취소하시겠습니까? 취소된 수주는 복구할 수 없습니다.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>닫기</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleConfirmCancel} className="bg-orange-600 hover:bg-orange-700">
|
||||
취소 확정
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{/* 수주 삭제 확인 다이얼로그 - 스크린샷 디자인 적용 */}
|
||||
<AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
|
||||
<AlertDialogContent className="max-w-md">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle className="flex items-center gap-2">
|
||||
<span className="text-yellow-600">⚠️</span>
|
||||
삭제 확인
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription asChild>
|
||||
<div className="space-y-4">
|
||||
<p className="text-foreground">
|
||||
선택한 <strong>{deleteTargetIds.length}개</strong>의 수주를 삭제하시겠습니까?
|
||||
</p>
|
||||
{/* 주의 박스 */}
|
||||
<div className="bg-gray-100 rounded-lg p-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="text-yellow-600 mt-0.5">⚠️</span>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
<span className="font-medium text-foreground">주의</span>
|
||||
<br />
|
||||
삭제된 수주는 복구할 수 없습니다. 관련된 작업지시, 출하정보도 함께 삭제될 수 있습니다.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>취소</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleConfirmDelete}
|
||||
className="bg-gray-900 hover:bg-gray-800 text-white gap-2"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
삭제
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,770 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 생산지시 상세 페이지
|
||||
*
|
||||
* - 공정 진행 현황
|
||||
* - 기본 정보 / 거래처/현장 정보
|
||||
* - BOM 품목별 공정 분류
|
||||
* - 작업지시서 목록
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useRouter, useParams } from "next/navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import {
|
||||
Factory,
|
||||
ArrowLeft,
|
||||
ClipboardList,
|
||||
CheckCircle2,
|
||||
Circle,
|
||||
Activity,
|
||||
Play,
|
||||
} from "lucide-react";
|
||||
import { PageLayout } from "@/components/organisms/PageLayout";
|
||||
import { PageHeader } from "@/components/organisms/PageHeader";
|
||||
import { BadgeSm } from "@/components/atoms/BadgeSm";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { toast } from "sonner";
|
||||
|
||||
// 생산지시 상태 타입
|
||||
type ProductionOrderStatus = "waiting" | "in_progress" | "completed";
|
||||
|
||||
// 작업지시 상태 타입
|
||||
type WorkOrderStatus = "pending" | "in_progress" | "completed";
|
||||
|
||||
// 작업지시 데이터 타입
|
||||
interface WorkOrder {
|
||||
id: string;
|
||||
workOrderNumber: string; // KD-WO-XXXXXX-XX
|
||||
process: string; // 공정명
|
||||
quantity: number;
|
||||
status: WorkOrderStatus;
|
||||
assignee: string;
|
||||
}
|
||||
|
||||
// 생산지시 상세 데이터 타입
|
||||
interface ProductionOrderDetail {
|
||||
id: string;
|
||||
productionOrderNumber: string;
|
||||
orderNumber: string;
|
||||
productionOrderDate: string;
|
||||
dueDate: string;
|
||||
quantity: number;
|
||||
status: ProductionOrderStatus;
|
||||
client: string;
|
||||
siteName: string;
|
||||
productType: string;
|
||||
pendingWorkOrderCount: number; // 생성 예정 작업지시 수
|
||||
workOrders: WorkOrder[];
|
||||
}
|
||||
|
||||
// 샘플 생산지시 상세 데이터
|
||||
const SAMPLE_PRODUCTION_ORDER_DETAILS: Record<string, ProductionOrderDetail> = {
|
||||
"PO-001": {
|
||||
id: "PO-001",
|
||||
productionOrderNumber: "PO-KD-TS-251217-07",
|
||||
orderNumber: "KD-TS-251217-07",
|
||||
productionOrderDate: "2025-12-22",
|
||||
dueDate: "2026-02-15",
|
||||
quantity: 2,
|
||||
status: "completed", // 생산완료 상태 - 목록 버튼만 표시
|
||||
client: "호반건설(주)",
|
||||
siteName: "씨밋 광교 센트럴시티",
|
||||
productType: "",
|
||||
pendingWorkOrderCount: 0, // 작업지시 이미 생성됨
|
||||
workOrders: [
|
||||
{
|
||||
id: "WO-001",
|
||||
workOrderNumber: "KD-WO-251217-07",
|
||||
process: "재단",
|
||||
quantity: 2,
|
||||
status: "completed",
|
||||
assignee: "-",
|
||||
},
|
||||
{
|
||||
id: "WO-002",
|
||||
workOrderNumber: "KD-WO-251217-08",
|
||||
process: "조립",
|
||||
quantity: 2,
|
||||
status: "completed",
|
||||
assignee: "-",
|
||||
},
|
||||
{
|
||||
id: "WO-003",
|
||||
workOrderNumber: "KD-WO-251217-09",
|
||||
process: "검수",
|
||||
quantity: 2,
|
||||
status: "completed",
|
||||
assignee: "-",
|
||||
},
|
||||
],
|
||||
},
|
||||
"PO-002": {
|
||||
id: "PO-002",
|
||||
productionOrderNumber: "PO-KD-TS-251217-09",
|
||||
orderNumber: "KD-TS-251217-09",
|
||||
productionOrderDate: "2025-12-22",
|
||||
dueDate: "2026-02-10",
|
||||
quantity: 10,
|
||||
status: "waiting",
|
||||
client: "태영건설(주)",
|
||||
siteName: "데시앙 동탄 파크뷰",
|
||||
productType: "",
|
||||
pendingWorkOrderCount: 5, // 생성 예정 작업지시 5개 (일괄생성)
|
||||
workOrders: [],
|
||||
},
|
||||
"PO-003": {
|
||||
id: "PO-003",
|
||||
productionOrderNumber: "PO-KD-TS-251217-06",
|
||||
orderNumber: "KD-TS-251217-06",
|
||||
productionOrderDate: "2025-12-22",
|
||||
dueDate: "2026-02-10",
|
||||
quantity: 1,
|
||||
status: "waiting",
|
||||
client: "롯데건설(주)",
|
||||
siteName: "예술 검실 푸르지오",
|
||||
productType: "",
|
||||
pendingWorkOrderCount: 1, // 생성 예정 작업지시 1개 (단일 생성)
|
||||
workOrders: [],
|
||||
},
|
||||
"PO-004": {
|
||||
id: "PO-004",
|
||||
productionOrderNumber: "PO-KD-BD-251220-35",
|
||||
orderNumber: "KD-BD-251220-35",
|
||||
productionOrderDate: "2025-12-20",
|
||||
dueDate: "2026-02-03",
|
||||
quantity: 3,
|
||||
status: "in_progress",
|
||||
client: "현대건설(주)",
|
||||
siteName: "[코레타스프] 판교 물류센터 철거현장",
|
||||
productType: "",
|
||||
pendingWorkOrderCount: 0,
|
||||
workOrders: [
|
||||
{
|
||||
id: "WO-004",
|
||||
workOrderNumber: "KD-WO-251220-01",
|
||||
process: "재단",
|
||||
quantity: 3,
|
||||
status: "completed",
|
||||
assignee: "-",
|
||||
},
|
||||
{
|
||||
id: "WO-005",
|
||||
workOrderNumber: "KD-WO-251220-02",
|
||||
process: "조립",
|
||||
quantity: 3,
|
||||
status: "in_progress",
|
||||
assignee: "-",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
// 공정 진행 현황 컴포넌트
|
||||
function ProcessProgress({ workOrders }: { workOrders: WorkOrder[] }) {
|
||||
if (workOrders.length === 0) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<Activity className="h-4 w-4" />
|
||||
공정 진행 현황
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-muted-foreground text-sm text-center py-4">
|
||||
아직 작업지시가 생성되지 않았습니다.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const completedCount = workOrders.filter((w) => w.status === "completed").length;
|
||||
const totalCount = workOrders.length;
|
||||
const progressPercent = Math.round((completedCount / totalCount) * 100);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<Activity className="h-4 w-4" />
|
||||
공정 진행 현황
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{/* 진행률 바 */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">진행률</span>
|
||||
<span className="font-medium">{progressPercent}%</span>
|
||||
</div>
|
||||
<div className="h-2 bg-gray-100 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-primary transition-all"
|
||||
style={{ width: `${progressPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 공정 단계 */}
|
||||
<div className="flex items-center justify-center gap-2 pt-2">
|
||||
{workOrders.map((wo, index) => (
|
||||
<div key={wo.id} className="flex items-center">
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<div
|
||||
className={`flex items-center justify-center w-8 h-8 rounded-full ${
|
||||
wo.status === "completed"
|
||||
? "bg-green-500 text-white"
|
||||
: wo.status === "in_progress"
|
||||
? "bg-blue-500 text-white"
|
||||
: "bg-gray-100 text-gray-400"
|
||||
}`}
|
||||
>
|
||||
{wo.status === "completed" ? (
|
||||
<CheckCircle2 className="h-4 w-4" />
|
||||
) : (
|
||||
<Circle className="h-4 w-4" />
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">{wo.process}</span>
|
||||
</div>
|
||||
{index < workOrders.length - 1 && (
|
||||
<div
|
||||
className={`w-12 h-0.5 mx-1 ${
|
||||
wo.status === "completed" ? "bg-green-500" : "bg-gray-200"
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// 상태 배지 헬퍼
|
||||
function getStatusBadge(status: ProductionOrderStatus) {
|
||||
const config: Record<ProductionOrderStatus, { label: string; className: string }> = {
|
||||
waiting: {
|
||||
label: "생산대기",
|
||||
className: "bg-yellow-100 text-yellow-700 border-yellow-200",
|
||||
},
|
||||
in_progress: {
|
||||
label: "생산중",
|
||||
className: "bg-green-100 text-green-700 border-green-200",
|
||||
},
|
||||
completed: {
|
||||
label: "생산완료",
|
||||
className: "bg-gray-100 text-gray-700 border-gray-200",
|
||||
},
|
||||
};
|
||||
const c = config[status];
|
||||
return <BadgeSm className={c.className}>{c.label}</BadgeSm>;
|
||||
}
|
||||
|
||||
// 작업지시 상태 배지 헬퍼
|
||||
function getWorkOrderStatusBadge(status: WorkOrderStatus) {
|
||||
const config: Record<WorkOrderStatus, { label: string; className: string }> = {
|
||||
pending: {
|
||||
label: "대기",
|
||||
className: "bg-gray-100 text-gray-700 border-gray-200",
|
||||
},
|
||||
in_progress: {
|
||||
label: "작업중",
|
||||
className: "bg-blue-100 text-blue-700 border-blue-200",
|
||||
},
|
||||
completed: {
|
||||
label: "완료",
|
||||
className: "bg-green-100 text-green-700 border-green-200",
|
||||
},
|
||||
};
|
||||
const c = config[status];
|
||||
return <BadgeSm className={c.className}>{c.label}</BadgeSm>;
|
||||
}
|
||||
|
||||
// 정보 표시 컴포넌트
|
||||
function InfoItem({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">{label}</p>
|
||||
<p className="font-medium">{value || "-"}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 샘플 공정 목록 (작업지시 생성 팝업에 표시용)
|
||||
const SAMPLE_PROCESSES = [
|
||||
{ id: "P1", name: "1.1 백판필름", quantity: 10 },
|
||||
{ id: "P2", name: "2. 하안마감재", quantity: 10 },
|
||||
{ id: "P3", name: "3.1 케이스", quantity: 10 },
|
||||
{ id: "P4", name: "4. 연기단자", quantity: 10 },
|
||||
{ id: "P5", name: "5. 가이드레일 하부브라켓", quantity: 10 },
|
||||
];
|
||||
|
||||
// BOM 품목 타입
|
||||
interface BomItem {
|
||||
id: string;
|
||||
itemCode: string;
|
||||
itemName: string;
|
||||
spec: string;
|
||||
lotNo: string;
|
||||
requiredQty: number;
|
||||
qty: number;
|
||||
}
|
||||
|
||||
// BOM 공정 분류 타입
|
||||
interface BomProcessGroup {
|
||||
processName: string;
|
||||
sizeSpec?: string;
|
||||
items: BomItem[];
|
||||
}
|
||||
|
||||
// BOM 품목별 공정 분류 목데이터
|
||||
const SAMPLE_BOM_PROCESS_GROUPS: BomProcessGroup[] = [
|
||||
{
|
||||
processName: "1.1 백판필름",
|
||||
sizeSpec: "[20-70]",
|
||||
items: [
|
||||
{ id: "B1", itemCode: "①", itemName: "아연판", spec: "EGI.6T", lotNo: "LOT-M-2024-001", requiredQty: 4300, qty: 8 },
|
||||
{ id: "B2", itemCode: "②", itemName: "가이드레일", spec: "EGI.6T", lotNo: "LOT-M-2024-002", requiredQty: 3000, qty: 4 },
|
||||
{ id: "B3", itemCode: "③", itemName: "C형", spec: "EGI.6ST", lotNo: "LOT-M-2024-003", requiredQty: 4300, qty: 4 },
|
||||
{ id: "B4", itemCode: "④", itemName: "D형", spec: "EGI.6T", lotNo: "LOT-M-2024-004", requiredQty: 4300, qty: 4 },
|
||||
{ id: "B5", itemCode: "⑤", itemName: "행거마감재", spec: "SUS1.2T", lotNo: "LOT-M-2024-005", requiredQty: 4300, qty: 2 },
|
||||
{ id: "B6", itemCode: "⑥", itemName: "R/RBASE", spec: "EGI.5ST", lotNo: "LOT-M-2024-006", requiredQty: 0, qty: 4 },
|
||||
],
|
||||
},
|
||||
{
|
||||
processName: "2. 하안마감재",
|
||||
sizeSpec: "[60-40]",
|
||||
items: [
|
||||
{ id: "B7", itemCode: "①", itemName: "하안마감재", spec: "EGI.5T", lotNo: "LOT-M-2024-007", requiredQty: 3000, qty: 4 },
|
||||
{ id: "B8", itemCode: "②", itemName: "하안보강재판", spec: "EGI.5T", lotNo: "LOT-M-2024-009", requiredQty: 3000, qty: 4 },
|
||||
{ id: "B9", itemCode: "③", itemName: "하안보강멈틀", spec: "EGI.1T", lotNo: "LOT-M-2024-010", requiredQty: 0, qty: 2 },
|
||||
{ id: "B10", itemCode: "④", itemName: "행거마감재", spec: "SUS1.2T", lotNo: "LOT-M-2024-008", requiredQty: 3000, qty: 2 },
|
||||
],
|
||||
},
|
||||
{
|
||||
processName: "3.1 케이스",
|
||||
sizeSpec: "[500*330]",
|
||||
items: [
|
||||
{ id: "B11", itemCode: "①", itemName: "전판", spec: "EGI.5ST", lotNo: "LOT-M-2024-011", requiredQty: 0, qty: 2 },
|
||||
{ id: "B12", itemCode: "②", itemName: "전멈틀", spec: "EGI.5T", lotNo: "LOT-M-2024-012", requiredQty: 0, qty: 4 },
|
||||
{ id: "B13", itemCode: "③⑤", itemName: "타부멈틀", spec: "", lotNo: "LOT-M-2024-013", requiredQty: 0, qty: 2 },
|
||||
{ id: "B14", itemCode: "④", itemName: "좌우판-HW", spec: "", lotNo: "LOT-M-2024-014", requiredQty: 0, qty: 2 },
|
||||
{ id: "B15", itemCode: "⑥", itemName: "상부판", spec: "", lotNo: "LOT-M-2024-015", requiredQty: 1220, qty: 5 },
|
||||
{ id: "B16", itemCode: "⑦", itemName: "측판(전산판)", spec: "", lotNo: "LOT-M-2024-016", requiredQty: 0, qty: 2 },
|
||||
],
|
||||
},
|
||||
{
|
||||
processName: "4. 연기단자",
|
||||
sizeSpec: "",
|
||||
items: [
|
||||
{ id: "B17", itemCode: "-", itemName: "레일홀 (W50)", spec: "EGI.8T + 화이버 금사", lotNo: "LOT-M-2024-017", requiredQty: 0, qty: 4 },
|
||||
{ id: "B18", itemCode: "-", itemName: "카이드홀 (W60)", spec: "EGI.8T + 화이버 금사", lotNo: "LOT-M-2024-018", requiredQty: 0, qty: 4 },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export default function ProductionOrderDetailPage() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const productionOrderId = params.id as string;
|
||||
|
||||
const [productionOrder, setProductionOrder] = useState<ProductionOrderDetail | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [isCreateWorkOrderDialogOpen, setIsCreateWorkOrderDialogOpen] = useState(false);
|
||||
const [isSuccessDialogOpen, setIsSuccessDialogOpen] = useState(false);
|
||||
const [createdWorkOrders, setCreatedWorkOrders] = useState<string[]>([]);
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
|
||||
// 데이터 로드
|
||||
useEffect(() => {
|
||||
setTimeout(() => {
|
||||
const found = SAMPLE_PRODUCTION_ORDER_DETAILS[productionOrderId];
|
||||
setProductionOrder(found || null);
|
||||
setLoading(false);
|
||||
}, 300);
|
||||
}, [productionOrderId]);
|
||||
|
||||
const handleBack = () => {
|
||||
router.push("/sales/order-management-sales/production-orders");
|
||||
};
|
||||
|
||||
const handleCreateWorkOrder = () => {
|
||||
setIsCreateWorkOrderDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleConfirmCreateWorkOrder = async () => {
|
||||
setIsCreating(true);
|
||||
try {
|
||||
// API 호출 시뮬레이션
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
|
||||
// 생성된 작업지시서 목록 (실제로는 API 응답에서 받음)
|
||||
const workOrderCount = productionOrder?.pendingWorkOrderCount || 0;
|
||||
const created = Array.from({ length: workOrderCount }, (_, i) =>
|
||||
`KD-WO-251223-${String(i + 1).padStart(2, "0")}`
|
||||
);
|
||||
setCreatedWorkOrders(created);
|
||||
|
||||
// 확인 팝업 닫고 성공 팝업 열기
|
||||
setIsCreateWorkOrderDialogOpen(false);
|
||||
setIsSuccessDialogOpen(true);
|
||||
} finally {
|
||||
setIsCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSuccessDialogClose = () => {
|
||||
setIsSuccessDialogOpen(false);
|
||||
// 작업지시 관리 페이지로 이동
|
||||
router.push("/production/work-orders");
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<PageLayout>
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
if (!productionOrder) {
|
||||
return (
|
||||
<PageLayout>
|
||||
<div className="text-center py-12">
|
||||
<p className="text-muted-foreground">생산지시 정보를 찾을 수 없습니다.</p>
|
||||
<Button variant="outline" onClick={handleBack} className="mt-4">
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
목록으로
|
||||
</Button>
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
{/* 헤더 */}
|
||||
<PageHeader
|
||||
title={
|
||||
<div className="flex items-center gap-3">
|
||||
<span>생산지시 상세</span>
|
||||
<code className="text-sm font-mono bg-blue-50 text-blue-700 px-2 py-1 rounded">
|
||||
{productionOrder.productionOrderNumber}
|
||||
</code>
|
||||
{getStatusBadge(productionOrder.status)}
|
||||
</div>
|
||||
}
|
||||
icon={Factory}
|
||||
actions={
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" onClick={handleBack}>
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
목록
|
||||
</Button>
|
||||
{/* 작업지시 생성 버튼 - 완료 아님 + 작업지시 없음 + 생성 예정 있음 */}
|
||||
{productionOrder.status !== "completed" &&
|
||||
productionOrder.workOrders.length === 0 &&
|
||||
productionOrder.pendingWorkOrderCount > 0 && (
|
||||
<Button onClick={handleCreateWorkOrder}>
|
||||
<ClipboardList className="h-4 w-4 mr-2" />
|
||||
작업지시 생성
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* 공정 진행 현황 */}
|
||||
<ProcessProgress workOrders={productionOrder.workOrders} />
|
||||
|
||||
{/* 기본 정보 & 거래처/현장 정보 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* 기본 정보 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">기본 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<InfoItem label="생산지시번호" value={productionOrder.productionOrderNumber} />
|
||||
<InfoItem label="수주번호" value={productionOrder.orderNumber} />
|
||||
<InfoItem label="생산지시일" value={productionOrder.productionOrderDate} />
|
||||
<InfoItem label="납기일" value={productionOrder.dueDate} />
|
||||
<InfoItem label="수량" value={`${productionOrder.quantity}개`} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 거래처/현장 정보 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">거래처/현장 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<InfoItem label="거래처" value={productionOrder.client} />
|
||||
<InfoItem label="현장명" value={productionOrder.siteName} />
|
||||
<InfoItem label="제품유형" value={productionOrder.productType} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* BOM 품목별 공정 분류 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">BOM 품목별 공정 분류</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{/* 절곡 부품 전개도 정보 헤더 */}
|
||||
<p className="text-sm font-medium text-muted-foreground border-b pb-2">
|
||||
절곡 부품 전개도 정보
|
||||
</p>
|
||||
|
||||
{/* 공정별 테이블 */}
|
||||
{SAMPLE_BOM_PROCESS_GROUPS.map((group) => (
|
||||
<div key={group.processName} className="space-y-2">
|
||||
{/* 공정명 헤더 */}
|
||||
<h4 className="text-sm font-semibold">
|
||||
{group.processName}
|
||||
{group.sizeSpec && (
|
||||
<span className="ml-2 text-muted-foreground font-normal">
|
||||
{group.sizeSpec}
|
||||
</span>
|
||||
)}
|
||||
</h4>
|
||||
|
||||
{/* BOM 품목 테이블 */}
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-gray-50">
|
||||
<TableHead className="w-[60px] text-center">항목코드</TableHead>
|
||||
<TableHead>세부품명</TableHead>
|
||||
<TableHead>규격</TableHead>
|
||||
<TableHead>LOT NO</TableHead>
|
||||
<TableHead className="text-right">필요수량</TableHead>
|
||||
<TableHead className="text-center w-[60px]">수량</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{group.items.map((item) => (
|
||||
<TableRow key={item.id}>
|
||||
<TableCell className="text-center font-medium">
|
||||
{item.itemCode}
|
||||
</TableCell>
|
||||
<TableCell>{item.itemName}</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{item.spec || "-"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<code className="text-xs bg-gray-100 px-1.5 py-0.5 rounded">
|
||||
{item.lotNo}
|
||||
</code>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{item.requiredQty > 0 ? item.requiredQty.toLocaleString() : "-"}
|
||||
</TableCell>
|
||||
<TableCell className="text-center">{item.qty}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* 합계 정보 */}
|
||||
<div className="flex justify-between items-center pt-4 border-t text-sm">
|
||||
<span className="text-muted-foreground">총 부품 종류: 18개</span>
|
||||
<span className="text-muted-foreground">총 중량: 25.8 kg</span>
|
||||
<span className="text-muted-foreground">비고: VT칼 작업 완료 후 절곡 진행</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 작업지시서 목록 */}
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle className="text-base">작업지시서 목록</CardTitle>
|
||||
{/* 버튼 조건: 완료 아님 + 작업지시 없음 + 생성 예정 있음 */}
|
||||
{productionOrder.status !== "completed" &&
|
||||
productionOrder.workOrders.length === 0 &&
|
||||
productionOrder.pendingWorkOrderCount > 0 && (
|
||||
<Button onClick={handleCreateWorkOrder}>
|
||||
<Play className="h-4 w-4 mr-2" />
|
||||
{productionOrder.pendingWorkOrderCount > 1
|
||||
? "작업지시 일괄생성"
|
||||
: "작업지시 생성"}
|
||||
</Button>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{productionOrder.workOrders.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<ClipboardList className="h-12 w-12 text-gray-300" />
|
||||
<p className="text-muted-foreground text-sm">
|
||||
아직 작업지시서가 생성되지 않았습니다.
|
||||
</p>
|
||||
{productionOrder.pendingWorkOrderCount > 0 && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
위 버튼을 클릭하여 BOM 기반 작업지시서를 자동 생성하세요.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>작업지시번호</TableHead>
|
||||
<TableHead>공정</TableHead>
|
||||
<TableHead className="text-center">수량</TableHead>
|
||||
<TableHead>상태</TableHead>
|
||||
<TableHead>담당자</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{productionOrder.workOrders.map((wo) => (
|
||||
<TableRow key={wo.id}>
|
||||
<TableCell>
|
||||
<code className="text-xs bg-gray-100 px-1.5 py-0.5 rounded">
|
||||
{wo.workOrderNumber}
|
||||
</code>
|
||||
</TableCell>
|
||||
<TableCell>{wo.process}</TableCell>
|
||||
<TableCell className="text-center">{wo.quantity}개</TableCell>
|
||||
<TableCell>{getWorkOrderStatusBadge(wo.status)}</TableCell>
|
||||
<TableCell>{wo.assignee}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 팝업1: 작업지시 생성 확인 다이얼로그 */}
|
||||
<AlertDialog
|
||||
open={isCreateWorkOrderDialogOpen}
|
||||
onOpenChange={setIsCreateWorkOrderDialogOpen}
|
||||
>
|
||||
<AlertDialogContent className="max-w-md">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle className="flex items-center gap-2">
|
||||
<Play className="h-5 w-5" />
|
||||
작업지시서 자동 생성
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription asChild>
|
||||
<div className="space-y-4 pt-2">
|
||||
<p className="font-medium text-foreground">
|
||||
다음 공정에 대한 작업지시서가 생성됩니다:
|
||||
</p>
|
||||
{/* 공정 목록 (실제로는 API에서 받아온 데이터) */}
|
||||
{productionOrder?.pendingWorkOrderCount && productionOrder.pendingWorkOrderCount > 0 && (
|
||||
<ul className="space-y-1 text-sm text-muted-foreground pl-4">
|
||||
{SAMPLE_PROCESSES.slice(0, productionOrder.pendingWorkOrderCount).map((process) => (
|
||||
<li key={process.id} className="flex items-center gap-2">
|
||||
<span className="w-1.5 h-1.5 bg-gray-400 rounded-full" />
|
||||
{process.name} ({process.quantity}개)
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<p className="text-muted-foreground">
|
||||
생성된 작업지시서는 생산팀에서 확인하고 작업을 진행할 수 있습니다.
|
||||
</p>
|
||||
</div>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isCreating}>취소</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleConfirmCreateWorkOrder}
|
||||
disabled={isCreating}
|
||||
className="gap-2"
|
||||
>
|
||||
<Play className="h-4 w-4" />
|
||||
{isCreating ? "생성 중..." : "작업지시 생성"}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{/* 팝업2: 작업지시 생성 성공 다이얼로그 */}
|
||||
<AlertDialog open={isSuccessDialogOpen} onOpenChange={setIsSuccessDialogOpen}>
|
||||
<AlertDialogContent className="max-w-md">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle className="sr-only">작업지시 생성 완료</AlertDialogTitle>
|
||||
<AlertDialogDescription asChild>
|
||||
<div className="space-y-4 pt-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle2 className="h-5 w-5 text-green-600" />
|
||||
<span className="font-medium text-foreground">
|
||||
{createdWorkOrders.length}개의 작업지시서가 공정별로 자동 생성되었습니다.
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-foreground mb-2">생성된 작업지시서:</p>
|
||||
{createdWorkOrders.length > 0 ? (
|
||||
<ul className="space-y-1 text-sm text-muted-foreground pl-4">
|
||||
{createdWorkOrders.map((wo, idx) => (
|
||||
<li key={wo} className="flex items-center gap-2">
|
||||
<span className="w-1.5 h-1.5 bg-green-500 rounded-full" />
|
||||
{wo}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">-</p>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-muted-foreground">
|
||||
작업지시 관리 페이지로 이동합니다.
|
||||
</p>
|
||||
</div>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogAction onClick={handleSuccessDialogClose}>
|
||||
확인
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,615 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 생산지시 목록 페이지
|
||||
*
|
||||
* - 수주관리 > 생산지시 보기에서 접근
|
||||
* - 진행 단계 바
|
||||
* - 필터 탭: 전체, 생산대기, 생산중, 생산완료 (TabChip 사용)
|
||||
* - IntegratedListTemplateV2 템플릿 적용
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
Factory,
|
||||
ArrowLeft,
|
||||
CheckCircle2,
|
||||
Eye,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
IntegratedListTemplateV2,
|
||||
TabOption,
|
||||
TableColumn,
|
||||
} from "@/components/templates/IntegratedListTemplateV2";
|
||||
import { BadgeSm } from "@/components/atoms/BadgeSm";
|
||||
import { ListMobileCard, InfoField } from "@/components/organisms/ListMobileCard";
|
||||
|
||||
// 생산지시 상태 타입
|
||||
type ProductionOrderStatus =
|
||||
| "waiting" // 생산대기
|
||||
| "in_progress" // 생산중
|
||||
| "completed"; // 생산완료
|
||||
|
||||
// 생산지시 데이터 타입
|
||||
interface ProductionOrder {
|
||||
id: string;
|
||||
productionOrderNumber: string; // PO-KD-TS-XXXXXX-XX
|
||||
orderNumber: string; // KD-TS-XXXXXX-XX
|
||||
siteName: string;
|
||||
client: string;
|
||||
quantity: number;
|
||||
dueDate: string;
|
||||
productionOrderDate: string;
|
||||
status: ProductionOrderStatus;
|
||||
workOrderCount: number;
|
||||
}
|
||||
|
||||
// 샘플 생산지시 데이터
|
||||
const SAMPLE_PRODUCTION_ORDERS: ProductionOrder[] = [
|
||||
{
|
||||
id: "PO-001",
|
||||
productionOrderNumber: "PO-KD-TS-251217-07",
|
||||
orderNumber: "KD-TS-251217-07",
|
||||
siteName: "씨밋 광교 센트럴시티",
|
||||
client: "호반건설(주)",
|
||||
quantity: 2,
|
||||
dueDate: "2026-02-15",
|
||||
productionOrderDate: "2025-12-22",
|
||||
status: "waiting",
|
||||
workOrderCount: 3,
|
||||
},
|
||||
{
|
||||
id: "PO-002",
|
||||
productionOrderNumber: "PO-KD-TS-251217-09",
|
||||
orderNumber: "KD-TS-251217-09",
|
||||
siteName: "데시앙 동탄 파크뷰",
|
||||
client: "태영건설(주)",
|
||||
quantity: 10,
|
||||
dueDate: "2026-02-10",
|
||||
productionOrderDate: "2025-12-22",
|
||||
status: "waiting",
|
||||
workOrderCount: 0,
|
||||
},
|
||||
{
|
||||
id: "PO-003",
|
||||
productionOrderNumber: "PO-KD-TS-251217-06",
|
||||
orderNumber: "KD-TS-251217-06",
|
||||
siteName: "예술 검실 푸르지오",
|
||||
client: "롯데건설(주)",
|
||||
quantity: 1,
|
||||
dueDate: "2026-02-10",
|
||||
productionOrderDate: "2025-12-22",
|
||||
status: "waiting",
|
||||
workOrderCount: 0,
|
||||
},
|
||||
{
|
||||
id: "PO-004",
|
||||
productionOrderNumber: "PO-KD-BD-251220-35",
|
||||
orderNumber: "KD-BD-251220-35",
|
||||
siteName: "[코레타스프] 판교 물류센터 철거현장",
|
||||
client: "현대건설(주)",
|
||||
quantity: 3,
|
||||
dueDate: "2026-02-03",
|
||||
productionOrderDate: "2025-12-20",
|
||||
status: "in_progress",
|
||||
workOrderCount: 2,
|
||||
},
|
||||
{
|
||||
id: "PO-005",
|
||||
productionOrderNumber: "PO-KD-BD-251219-34",
|
||||
orderNumber: "KD-BD-251219-34",
|
||||
siteName: "[코레타스프1] 김포 6차 필라테스장",
|
||||
client: "신성플랜(주)",
|
||||
quantity: 2,
|
||||
dueDate: "2026-01-15",
|
||||
productionOrderDate: "2025-12-19",
|
||||
status: "in_progress",
|
||||
workOrderCount: 3,
|
||||
},
|
||||
{
|
||||
id: "PO-006",
|
||||
productionOrderNumber: "PO-KD-TS-250401-29",
|
||||
orderNumber: "KD-TS-250401-29",
|
||||
siteName: "포레나 전주",
|
||||
client: "한화건설(주)",
|
||||
quantity: 2,
|
||||
dueDate: "2025-05-16",
|
||||
productionOrderDate: "2025-04-01",
|
||||
status: "completed",
|
||||
workOrderCount: 3,
|
||||
},
|
||||
{
|
||||
id: "PO-007",
|
||||
productionOrderNumber: "PO-KD-BD-250331-28",
|
||||
orderNumber: "KD-BD-250331-28",
|
||||
siteName: "포레나 수원",
|
||||
client: "포레나건설(주)",
|
||||
quantity: 4,
|
||||
dueDate: "2025-05-15",
|
||||
productionOrderDate: "2025-03-31",
|
||||
status: "completed",
|
||||
workOrderCount: 3,
|
||||
},
|
||||
{
|
||||
id: "PO-008",
|
||||
productionOrderNumber: "PO-KD-TS-250314-23",
|
||||
orderNumber: "KD-TS-250314-23",
|
||||
siteName: "자이 흑산파크",
|
||||
client: "GS건설(주)",
|
||||
quantity: 3,
|
||||
dueDate: "2025-04-28",
|
||||
productionOrderDate: "2025-03-14",
|
||||
status: "completed",
|
||||
workOrderCount: 3,
|
||||
},
|
||||
];
|
||||
|
||||
// 진행 단계 컴포넌트
|
||||
function ProgressSteps() {
|
||||
const steps = [
|
||||
{ label: "수주확정", active: true, completed: true },
|
||||
{ label: "생산지시", active: true, completed: false },
|
||||
{ label: "작업지시", active: false, completed: false },
|
||||
{ label: "생산", active: false, completed: false },
|
||||
{ label: "검사출하", active: false, completed: false },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center gap-2 py-4">
|
||||
{steps.map((step, index) => (
|
||||
<div key={step.label} className="flex items-center">
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className={`flex items-center justify-center w-6 h-6 rounded-full text-xs font-medium ${
|
||||
step.completed
|
||||
? "bg-primary text-primary-foreground"
|
||||
: step.active
|
||||
? "bg-primary/20 text-primary border-2 border-primary"
|
||||
: "bg-gray-100 text-gray-400"
|
||||
}`}
|
||||
>
|
||||
{step.completed ? (
|
||||
<CheckCircle2 className="h-4 w-4" />
|
||||
) : (
|
||||
index + 1
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
className={`text-sm ${
|
||||
step.active || step.completed
|
||||
? "text-foreground font-medium"
|
||||
: "text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
{step.label}
|
||||
</span>
|
||||
</div>
|
||||
{index < steps.length - 1 && (
|
||||
<div
|
||||
className={`w-8 h-0.5 mx-2 ${
|
||||
step.completed ? "bg-primary" : "bg-gray-200"
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 상태 배지 헬퍼
|
||||
function getStatusBadge(status: ProductionOrderStatus) {
|
||||
const config: Record<
|
||||
ProductionOrderStatus,
|
||||
{ label: string; className: string }
|
||||
> = {
|
||||
waiting: {
|
||||
label: "생산대기",
|
||||
className: "bg-yellow-100 text-yellow-700 border-yellow-200",
|
||||
},
|
||||
in_progress: {
|
||||
label: "생산중",
|
||||
className: "bg-green-100 text-green-700 border-green-200",
|
||||
},
|
||||
completed: {
|
||||
label: "생산완료",
|
||||
className: "bg-gray-100 text-gray-700 border-gray-200",
|
||||
},
|
||||
};
|
||||
const c = config[status];
|
||||
return <BadgeSm className={c.className}>{c.label}</BadgeSm>;
|
||||
}
|
||||
|
||||
// 테이블 컬럼 정의
|
||||
const TABLE_COLUMNS: TableColumn[] = [
|
||||
{ key: "no", label: "번호", className: "w-[60px] text-center" },
|
||||
{ key: "productionOrderNumber", label: "생산지시번호", className: "min-w-[150px]" },
|
||||
{ key: "orderNumber", label: "수주번호", className: "min-w-[140px]" },
|
||||
{ key: "siteName", label: "현장명", className: "min-w-[180px]" },
|
||||
{ key: "client", label: "거래처", className: "min-w-[120px]" },
|
||||
{ key: "quantity", label: "수량", className: "w-[80px] text-center" },
|
||||
{ key: "dueDate", label: "납기", className: "w-[110px]" },
|
||||
{ key: "productionOrderDate", label: "생산지시일", className: "w-[110px]" },
|
||||
{ key: "status", label: "상태", className: "w-[100px]" },
|
||||
{ key: "workOrderCount", label: "작업지시", className: "w-[80px] text-center" },
|
||||
{ key: "actions", label: "작업", className: "w-[100px] text-center" },
|
||||
];
|
||||
|
||||
export default function ProductionOrdersListPage() {
|
||||
const router = useRouter();
|
||||
const [orders, setOrders] = useState<ProductionOrder[]>(SAMPLE_PRODUCTION_ORDERS);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [activeTab, setActiveTab] = useState("all");
|
||||
const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set());
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const itemsPerPage = 20;
|
||||
|
||||
// 삭제 확인 다이얼로그 상태
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||
const [deleteTargetId, setDeleteTargetId] = useState<string | null>(null); // 개별 삭제 시 사용
|
||||
|
||||
// 필터링된 데이터
|
||||
const filteredData = orders.filter((item) => {
|
||||
// 탭 필터
|
||||
if (activeTab !== "all") {
|
||||
const statusMap: Record<string, ProductionOrderStatus> = {
|
||||
waiting: "waiting",
|
||||
in_progress: "in_progress",
|
||||
completed: "completed",
|
||||
};
|
||||
if (item.status !== statusMap[activeTab]) return false;
|
||||
}
|
||||
|
||||
// 검색 필터
|
||||
if (searchTerm) {
|
||||
const term = searchTerm.toLowerCase();
|
||||
return (
|
||||
item.productionOrderNumber.toLowerCase().includes(term) ||
|
||||
item.orderNumber.toLowerCase().includes(term) ||
|
||||
item.siteName.toLowerCase().includes(term) ||
|
||||
item.client.toLowerCase().includes(term)
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
// 페이지네이션
|
||||
const totalPages = Math.ceil(filteredData.length / itemsPerPage);
|
||||
const paginatedData = filteredData.slice(
|
||||
(currentPage - 1) * itemsPerPage,
|
||||
currentPage * itemsPerPage
|
||||
);
|
||||
|
||||
// 탭별 건수
|
||||
const tabCounts = {
|
||||
all: orders.length,
|
||||
waiting: orders.filter((i) => i.status === "waiting").length,
|
||||
in_progress: orders.filter((i) => i.status === "in_progress").length,
|
||||
completed: orders.filter((i) => i.status === "completed").length,
|
||||
};
|
||||
|
||||
// 탭 옵션
|
||||
const tabs: TabOption[] = [
|
||||
{ value: "all", label: "전체", count: tabCounts.all },
|
||||
{ value: "waiting", label: "생산대기", count: tabCounts.waiting, color: "yellow" },
|
||||
{ value: "in_progress", label: "생산중", count: tabCounts.in_progress, color: "green" },
|
||||
{ value: "completed", label: "생산완료", count: tabCounts.completed, color: "gray" },
|
||||
];
|
||||
|
||||
const handleBack = () => {
|
||||
router.push("/sales/order-management-sales");
|
||||
};
|
||||
|
||||
const handleRowClick = (item: ProductionOrder) => {
|
||||
router.push(`/sales/order-management-sales/production-orders/${item.id}`);
|
||||
};
|
||||
|
||||
const handleView = (item: ProductionOrder) => {
|
||||
router.push(`/sales/order-management-sales/production-orders/${item.id}`);
|
||||
};
|
||||
|
||||
// 개별 삭제 다이얼로그 열기
|
||||
const handleDelete = (item: ProductionOrder) => {
|
||||
setDeleteTargetId(item.id);
|
||||
setShowDeleteDialog(true);
|
||||
};
|
||||
|
||||
// 체크박스 선택
|
||||
const toggleSelection = (id: string) => {
|
||||
const newSelection = new Set(selectedItems);
|
||||
if (newSelection.has(id)) {
|
||||
newSelection.delete(id);
|
||||
} else {
|
||||
newSelection.add(id);
|
||||
}
|
||||
setSelectedItems(newSelection);
|
||||
};
|
||||
|
||||
const toggleSelectAll = () => {
|
||||
if (selectedItems.size === paginatedData.length) {
|
||||
setSelectedItems(new Set());
|
||||
} else {
|
||||
setSelectedItems(new Set(paginatedData.map((item) => item.id)));
|
||||
}
|
||||
};
|
||||
|
||||
// 일괄 삭제 다이얼로그 열기
|
||||
const handleBulkDelete = () => {
|
||||
if (selectedItems.size > 0) {
|
||||
setDeleteTargetId(null); // 일괄 삭제
|
||||
setShowDeleteDialog(true);
|
||||
}
|
||||
};
|
||||
|
||||
// 삭제 개수 계산 (개별 삭제 시 1, 일괄 삭제 시 selectedItems.size)
|
||||
const deleteCount = deleteTargetId ? 1 : selectedItems.size;
|
||||
|
||||
// 실제 삭제 실행
|
||||
const handleConfirmDelete = () => {
|
||||
if (deleteTargetId) {
|
||||
// 개별 삭제
|
||||
setOrders(orders.filter((o) => o.id !== deleteTargetId));
|
||||
setSelectedItems(new Set([...selectedItems].filter(id => id !== deleteTargetId)));
|
||||
} else {
|
||||
// 일괄 삭제
|
||||
const selectedIds = Array.from(selectedItems);
|
||||
setOrders(orders.filter((o) => !selectedIds.includes(o.id)));
|
||||
setSelectedItems(new Set());
|
||||
}
|
||||
setShowDeleteDialog(false);
|
||||
setDeleteTargetId(null);
|
||||
};
|
||||
|
||||
// 테이블 행 렌더링
|
||||
const renderTableRow = (
|
||||
item: ProductionOrder,
|
||||
index: number,
|
||||
globalIndex: number
|
||||
) => {
|
||||
const isSelected = selectedItems.has(item.id);
|
||||
return (
|
||||
<TableRow
|
||||
key={item.id}
|
||||
className={`cursor-pointer hover:bg-muted/50 ${isSelected ? "bg-muted/30" : ""}`}
|
||||
onClick={() => handleRowClick(item)}
|
||||
>
|
||||
<TableCell onClick={(e) => e.stopPropagation()} className="text-center">
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={() => toggleSelection(item.id)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-center text-muted-foreground">
|
||||
{globalIndex}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<code className="text-xs bg-blue-50 text-blue-700 px-1.5 py-0.5 rounded">
|
||||
{item.productionOrderNumber}
|
||||
</code>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<code className="text-xs bg-gray-100 px-1.5 py-0.5 rounded">
|
||||
{item.orderNumber}
|
||||
</code>
|
||||
</TableCell>
|
||||
<TableCell className="max-w-[200px] truncate">
|
||||
{item.siteName}
|
||||
</TableCell>
|
||||
<TableCell>{item.client}</TableCell>
|
||||
<TableCell className="text-center">{item.quantity}개</TableCell>
|
||||
<TableCell>{item.dueDate}</TableCell>
|
||||
<TableCell>{item.productionOrderDate}</TableCell>
|
||||
<TableCell>{getStatusBadge(item.status)}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{item.workOrderCount > 0 ? (
|
||||
<Badge variant="outline">{item.workOrderCount}건</Badge>
|
||||
) : (
|
||||
<span className="text-muted-foreground">-</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell onClick={(e) => e.stopPropagation()}>
|
||||
{isSelected && (
|
||||
<div className="flex gap-1 justify-center">
|
||||
<Button variant="ghost" size="sm" onClick={() => handleView(item)}>
|
||||
<Eye className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => handleDelete(item)}>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
};
|
||||
|
||||
// 모바일 카드 렌더링
|
||||
const renderMobileCard = (
|
||||
item: ProductionOrder,
|
||||
index: number,
|
||||
globalIndex: number,
|
||||
isSelected: boolean,
|
||||
onToggle: () => void
|
||||
) => {
|
||||
return (
|
||||
<ListMobileCard
|
||||
key={item.id}
|
||||
id={item.id}
|
||||
isSelected={isSelected}
|
||||
onToggleSelection={onToggle}
|
||||
onCardClick={() => handleRowClick(item)}
|
||||
headerBadges={
|
||||
<>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="bg-blue-50 text-blue-700 font-mono text-xs"
|
||||
>
|
||||
{item.productionOrderNumber}
|
||||
</Badge>
|
||||
{getStatusBadge(item.status)}
|
||||
</>
|
||||
}
|
||||
fields={
|
||||
<>
|
||||
<InfoField label="수주번호" value={item.orderNumber} />
|
||||
<InfoField label="현장명" value={item.siteName} />
|
||||
<InfoField label="거래처" value={item.client} />
|
||||
<InfoField label="수량" value={`${item.quantity}개`} />
|
||||
<InfoField label="납기" value={item.dueDate} />
|
||||
<InfoField label="생산지시일" value={item.productionOrderDate} />
|
||||
<InfoField
|
||||
label="작업지시"
|
||||
value={item.workOrderCount > 0 ? `${item.workOrderCount}건` : "-"}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
actions={
|
||||
isSelected ? (
|
||||
<div className="flex gap-2 w-full">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="default"
|
||||
className="flex-1 min-w-[100px] h-11"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleView(item);
|
||||
}}
|
||||
>
|
||||
<Eye className="h-4 w-4 mr-2" />
|
||||
상세
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="default"
|
||||
className="flex-1 min-w-[100px] h-11"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDelete(item);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
삭제
|
||||
</Button>
|
||||
</div>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<IntegratedListTemplateV2
|
||||
title="생산지시 목록"
|
||||
icon={Factory}
|
||||
headerActions={
|
||||
<Button variant="outline" onClick={handleBack}>
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
수주 목록
|
||||
</Button>
|
||||
}
|
||||
// 진행 단계 표시
|
||||
tabsContent={
|
||||
<Card className="w-full mb-4">
|
||||
<CardContent className="py-2">
|
||||
<ProgressSteps />
|
||||
</CardContent>
|
||||
</Card>
|
||||
}
|
||||
// 검색
|
||||
searchValue={searchTerm}
|
||||
onSearchChange={setSearchTerm}
|
||||
searchPlaceholder="생산지시번호, 수주번호, 현장명 검색..."
|
||||
// 탭
|
||||
tabs={tabs}
|
||||
activeTab={activeTab}
|
||||
onTabChange={(value) => {
|
||||
setActiveTab(value);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
// 테이블
|
||||
tableColumns={TABLE_COLUMNS}
|
||||
data={paginatedData}
|
||||
allData={filteredData}
|
||||
selectedItems={selectedItems}
|
||||
onToggleSelection={toggleSelection}
|
||||
onToggleSelectAll={toggleSelectAll}
|
||||
getItemId={(item) => item.id}
|
||||
onBulkDelete={handleBulkDelete}
|
||||
renderTableRow={renderTableRow}
|
||||
renderMobileCard={renderMobileCard}
|
||||
pagination={{
|
||||
currentPage,
|
||||
totalPages,
|
||||
totalItems: filteredData.length,
|
||||
itemsPerPage,
|
||||
onPageChange: setCurrentPage,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 삭제 확인 다이얼로그 */}
|
||||
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
|
||||
<AlertDialogContent className="max-w-md">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle className="flex items-center gap-2">
|
||||
<span className="text-yellow-600">⚠️</span>
|
||||
삭제 확인
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription asChild>
|
||||
<div className="space-y-4">
|
||||
<p className="text-foreground">
|
||||
선택한 <strong>{deleteCount}개</strong>의 항목을 삭제하시겠습니까?
|
||||
</p>
|
||||
<div className="bg-gray-100 rounded-lg p-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="text-yellow-600 mt-0.5">⚠️</span>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
<span className="font-medium text-foreground">주의</span>
|
||||
<br />
|
||||
삭제된 항목은 복구할 수 없습니다. 관련된 데이터도 함께 삭제될 수 있습니다.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>취소</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleConfirmDelete}
|
||||
className="bg-gray-900 hover:bg-gray-800 text-white gap-2"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
삭제
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { getMessages } from 'next-intl/server';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { locales, type Locale } from '@/i18n/config';
|
||||
import { ThemeProvider } from '@/contexts/ThemeContext';
|
||||
import { Toaster } from 'sonner';
|
||||
import "../globals.css";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
@@ -68,6 +69,7 @@ export default async function RootLayout({
|
||||
<ThemeProvider>
|
||||
<NextIntlClientProvider messages={messages}>
|
||||
{children}
|
||||
<Toaster richColors position="top-right" />
|
||||
</NextIntlClientProvider>
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
|
||||
@@ -5,9 +5,28 @@ import { format, subDays, subMonths, startOfMonth, endOfMonth } from 'date-fns';
|
||||
import {
|
||||
CreditCard,
|
||||
RefreshCw,
|
||||
Save,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { TableRow, TableCell } from '@/components/ui/table';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -28,6 +47,7 @@ import type {
|
||||
} from './types';
|
||||
import {
|
||||
SORT_OPTIONS,
|
||||
ACCOUNT_SUBJECT_OPTIONS,
|
||||
} from './types';
|
||||
|
||||
// ===== Mock 데이터 생성 (결정론적: seed 기반으로 일관된 값 생성) =====
|
||||
@@ -65,9 +85,19 @@ export function CardTransactionInquiry() {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [sortOption, setSortOption] = useState<SortOption>('latest');
|
||||
const [cardFilter, setCardFilter] = useState<string>('all'); // 카드명 필터
|
||||
const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set());
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const itemsPerPage = 20;
|
||||
|
||||
// 상단 계정과목명 선택 (저장용)
|
||||
const [selectedAccountSubject, setSelectedAccountSubject] = useState<string>('unset');
|
||||
|
||||
// 계정과목명 저장 다이얼로그
|
||||
const [showSaveDialog, setShowSaveDialog] = useState(false);
|
||||
|
||||
// 선택 필요 알림 다이얼로그
|
||||
const [showSelectWarningDialog, setShowSelectWarningDialog] = useState(false);
|
||||
|
||||
// 날짜 범위 상태
|
||||
const [startDate, setStartDate] = useState('2025-09-01');
|
||||
const [endDate, setEndDate] = useState('2025-09-03');
|
||||
@@ -75,6 +105,16 @@ export function CardTransactionInquiry() {
|
||||
// Mock 데이터 (SSR-safe: 결정론적 데이터)
|
||||
const [data] = useState<CardTransaction[]>(() => generateMockData());
|
||||
|
||||
// ===== 체크박스 핸들러 =====
|
||||
const toggleSelection = useCallback((id: string) => {
|
||||
setSelectedItems(prev => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(id)) newSet.delete(id);
|
||||
else newSet.add(id);
|
||||
return newSet;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// ===== 카드명 옵션 =====
|
||||
const cardOptions = useMemo(() => {
|
||||
const uniqueCards = [...new Set(data.map(d => d.cardName))];
|
||||
@@ -130,6 +170,32 @@ export function CardTransactionInquiry() {
|
||||
// TODO: API 호출로 최신 데이터 조회
|
||||
}, []);
|
||||
|
||||
// ===== 전체 선택 핸들러 =====
|
||||
const toggleSelectAll = useCallback(() => {
|
||||
if (selectedItems.size === filteredData.length && filteredData.length > 0) {
|
||||
setSelectedItems(new Set());
|
||||
} else {
|
||||
setSelectedItems(new Set(filteredData.map(item => item.id)));
|
||||
}
|
||||
}, [selectedItems.size, filteredData]);
|
||||
|
||||
// ===== 계정과목명 저장 핸들러 =====
|
||||
const handleSaveAccountSubject = useCallback(() => {
|
||||
if (selectedItems.size === 0) {
|
||||
setShowSelectWarningDialog(true);
|
||||
return;
|
||||
}
|
||||
setShowSaveDialog(true);
|
||||
}, [selectedItems.size]);
|
||||
|
||||
// 계정과목명 저장 확정
|
||||
const handleConfirmSaveAccountSubject = useCallback(() => {
|
||||
console.log('계정과목명 저장:', selectedAccountSubject, '선택된 항목:', Array.from(selectedItems));
|
||||
// TODO: API 호출로 저장
|
||||
setShowSaveDialog(false);
|
||||
setSelectedItems(new Set());
|
||||
}, [selectedAccountSubject, selectedItems]);
|
||||
|
||||
// ===== 통계 카드 (전월/당월 사용액) =====
|
||||
// Mock 데이터 기준일(2025-12-01)에 맞춰 고정된 날짜 사용 (Hydration 오류 방지)
|
||||
const statCards: StatCard[] = useMemo(() => {
|
||||
@@ -172,13 +238,19 @@ export function CardTransactionInquiry() {
|
||||
{ key: 'amount', label: '사용금액', className: 'text-right' },
|
||||
], []);
|
||||
|
||||
// ===== 테이블 행 렌더링 (체크박스/번호 없음) =====
|
||||
// ===== 테이블 행 렌더링 =====
|
||||
const renderTableRow = useCallback((item: CardTransaction) => {
|
||||
const isSelected = selectedItems.has(item.id);
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
key={item.id}
|
||||
className="hover:bg-muted/50"
|
||||
>
|
||||
{/* 체크박스 */}
|
||||
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
|
||||
<Checkbox checked={isSelected} onCheckedChange={() => toggleSelection(item.id)} />
|
||||
</TableCell>
|
||||
{/* 카드 */}
|
||||
<TableCell>{item.card}</TableCell>
|
||||
{/* 카드명 */}
|
||||
@@ -195,7 +267,7 @@ export function CardTransactionInquiry() {
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}, []);
|
||||
}, [selectedItems, toggleSelection]);
|
||||
|
||||
// ===== 모바일 카드 렌더링 =====
|
||||
const renderMobileCard = useCallback((
|
||||
@@ -209,9 +281,9 @@ export function CardTransactionInquiry() {
|
||||
<ListMobileCard
|
||||
id={item.id}
|
||||
title={`${item.card} - ${item.cardName}`}
|
||||
isSelected={false}
|
||||
onToggleSelection={() => {}}
|
||||
showCheckbox={false}
|
||||
isSelected={isSelected}
|
||||
onToggleSelection={onToggle}
|
||||
showCheckbox={true}
|
||||
infoGrid={
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<InfoField label="사용자" value={item.user} />
|
||||
@@ -234,9 +306,28 @@ export function CardTransactionInquiry() {
|
||||
/>
|
||||
);
|
||||
|
||||
// ===== 테이블 상단 새로고침 버튼 (출금관리 스타일) =====
|
||||
// ===== 상단 계정과목명 + 저장 버튼 + 새로고침 =====
|
||||
const beforeTableContent = (
|
||||
<div className="flex items-center justify-end w-full">
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-gray-700">계정과목명</span>
|
||||
<Select value={selectedAccountSubject} onValueChange={setSelectedAccountSubject}>
|
||||
<SelectTrigger className="w-[150px]">
|
||||
<SelectValue placeholder="계정과목명 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ACCOUNT_SUBJECT_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button onClick={handleSaveAccountSubject} className="bg-orange-500 hover:bg-orange-600">
|
||||
<Save className="h-4 w-4 mr-2" />
|
||||
저장
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleRefresh}
|
||||
@@ -288,6 +379,7 @@ export function CardTransactionInquiry() {
|
||||
// ===== 테이블 하단 합계 행 =====
|
||||
const tableFooter = (
|
||||
<TableRow className="bg-muted/50 font-medium">
|
||||
<TableCell className="text-center"></TableCell>
|
||||
<TableCell colSpan={5} className="text-right font-bold">합계</TableCell>
|
||||
<TableCell className="text-right font-bold">
|
||||
{totalAmount.toLocaleString()}
|
||||
@@ -296,37 +388,83 @@ export function CardTransactionInquiry() {
|
||||
);
|
||||
|
||||
return (
|
||||
<IntegratedListTemplateV2
|
||||
title="카드 내역 조회"
|
||||
description="법인카드 사용 내역을 조회합니다"
|
||||
icon={CreditCard}
|
||||
headerActions={headerActions}
|
||||
stats={statCards}
|
||||
searchValue={searchQuery}
|
||||
onSearchChange={setSearchQuery}
|
||||
searchPlaceholder="카드, 카드명, 사용자, 가맹점명 검색..."
|
||||
beforeTableContent={beforeTableContent}
|
||||
tableHeaderActions={tableHeaderActions}
|
||||
tableColumns={tableColumns}
|
||||
tableFooter={tableFooter}
|
||||
data={paginatedData}
|
||||
totalCount={filteredData.length}
|
||||
allData={filteredData}
|
||||
selectedItems={new Set()}
|
||||
onToggleSelection={() => {}}
|
||||
onToggleSelectAll={() => {}}
|
||||
getItemId={(item: CardTransaction) => item.id}
|
||||
renderTableRow={renderTableRow}
|
||||
renderMobileCard={renderMobileCard}
|
||||
showCheckbox={false}
|
||||
showRowNumber={false}
|
||||
pagination={{
|
||||
currentPage,
|
||||
totalPages,
|
||||
totalItems: filteredData.length,
|
||||
itemsPerPage,
|
||||
onPageChange: setCurrentPage,
|
||||
}}
|
||||
/>
|
||||
<>
|
||||
<IntegratedListTemplateV2
|
||||
title="카드 내역 조회"
|
||||
description="법인카드 사용 내역을 조회합니다"
|
||||
icon={CreditCard}
|
||||
headerActions={headerActions}
|
||||
stats={statCards}
|
||||
searchValue={searchQuery}
|
||||
onSearchChange={setSearchQuery}
|
||||
searchPlaceholder="카드, 카드명, 사용자, 가맹점명 검색..."
|
||||
beforeTableContent={beforeTableContent}
|
||||
tableHeaderActions={tableHeaderActions}
|
||||
tableColumns={tableColumns}
|
||||
tableFooter={tableFooter}
|
||||
data={paginatedData}
|
||||
totalCount={filteredData.length}
|
||||
allData={filteredData}
|
||||
selectedItems={selectedItems}
|
||||
onToggleSelection={toggleSelection}
|
||||
onToggleSelectAll={toggleSelectAll}
|
||||
getItemId={(item: CardTransaction) => item.id}
|
||||
renderTableRow={renderTableRow}
|
||||
renderMobileCard={renderMobileCard}
|
||||
showCheckbox={true}
|
||||
showRowNumber={false}
|
||||
pagination={{
|
||||
currentPage,
|
||||
totalPages,
|
||||
totalItems: filteredData.length,
|
||||
itemsPerPage,
|
||||
onPageChange: setCurrentPage,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 계정과목명 저장 확인 다이얼로그 */}
|
||||
<Dialog open={showSaveDialog} onOpenChange={setShowSaveDialog}>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>계정과목명 변경</DialogTitle>
|
||||
<DialogDescription>
|
||||
{selectedItems.size}개의 카드 사용 내역을{' '}
|
||||
<span className="font-semibold text-orange-500">
|
||||
{ACCOUNT_SUBJECT_OPTIONS.find(o => o.value === selectedAccountSubject)?.label}
|
||||
</span>
|
||||
(으)로 모두 변경하시겠습니까?
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
<Button variant="outline" onClick={() => setShowSaveDialog(false)}>
|
||||
취소
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleConfirmSaveAccountSubject}
|
||||
className="bg-orange-500 hover:bg-orange-600"
|
||||
>
|
||||
확인
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 선택 필요 알림 다이얼로그 */}
|
||||
<AlertDialog open={showSelectWarningDialog} onOpenChange={setShowSelectWarningDialog}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>항목 선택 필요</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
변경할 카드 사용 내역을 먼저 선택해주세요.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogAction onClick={() => setShowSelectWarningDialog(false)}>
|
||||
확인
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -23,4 +23,24 @@ export const SORT_OPTIONS: { value: SortOption; label: string }[] = [
|
||||
{ value: 'oldest', label: '등록순' },
|
||||
{ value: 'amountHigh', label: '금액높은순' },
|
||||
{ value: 'amountLow', label: '금액낮은순' },
|
||||
];
|
||||
|
||||
// ===== 계정과목명 옵션 (상단 셀렉트) =====
|
||||
export const ACCOUNT_SUBJECT_OPTIONS = [
|
||||
{ value: 'unset', label: '미설정' },
|
||||
{ value: 'purchasePayment', label: '매입대금' },
|
||||
{ value: 'advance', label: '선급금' },
|
||||
{ value: 'suspense', label: '가지급금' },
|
||||
{ value: 'rent', label: '임대료' },
|
||||
{ value: 'interestExpense', label: '이자비용' },
|
||||
{ value: 'depositPayment', label: '보증금 지급' },
|
||||
{ value: 'loanRepayment', label: '차입금 상환' },
|
||||
{ value: 'dividendPayment', label: '배당금 지급' },
|
||||
{ value: 'vatPayment', label: '부가세 납부' },
|
||||
{ value: 'salary', label: '급여' },
|
||||
{ value: 'insurance', label: '4대보험' },
|
||||
{ value: 'tax', label: '세금' },
|
||||
{ value: 'utilities', label: '공과금' },
|
||||
{ value: 'expenses', label: '경비' },
|
||||
{ value: 'other', label: '기타' },
|
||||
];
|
||||
@@ -3,6 +3,14 @@
|
||||
import { useState, useCallback, useMemo } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Building2, Trash2, Plus, X } from 'lucide-react';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
|
||||
// 필드명 매핑
|
||||
const FIELD_NAME_MAP: Record<string, string> = {
|
||||
businessNumber: '사업자등록번호',
|
||||
vendorName: '거래처명',
|
||||
category: '거래처 유형',
|
||||
};
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
@@ -143,9 +151,30 @@ export function VendorDetail({ mode, vendorId }: VendorDetailProps) {
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||
const [showSaveDialog, setShowSaveDialog] = useState(false);
|
||||
|
||||
// Validation 에러 상태
|
||||
const [validationErrors, setValidationErrors] = useState<Record<string, string>>({});
|
||||
|
||||
// 새 메모 입력
|
||||
const [newMemo, setNewMemo] = useState('');
|
||||
|
||||
// Validation 함수
|
||||
const validateForm = useCallback(() => {
|
||||
const errors: Record<string, string> = {};
|
||||
|
||||
if (!formData.businessNumber?.trim()) {
|
||||
errors.businessNumber = '사업자등록번호를 입력해주세요';
|
||||
}
|
||||
if (!formData.vendorName?.trim()) {
|
||||
errors.vendorName = '거래처명을 입력해주세요';
|
||||
}
|
||||
if (!formData.category) {
|
||||
errors.category = '거래처 유형을 선택해주세요';
|
||||
}
|
||||
|
||||
setValidationErrors(errors);
|
||||
return Object.keys(errors).length === 0;
|
||||
}, [formData.businessNumber, formData.vendorName, formData.category]);
|
||||
|
||||
// 필드 변경 핸들러
|
||||
const handleChange = useCallback((field: string, value: string | number | boolean) => {
|
||||
setFormData(prev => ({ ...prev, [field]: value }));
|
||||
@@ -170,8 +199,15 @@ export function VendorDetail({ mode, vendorId }: VendorDetailProps) {
|
||||
|
||||
// 저장 핸들러
|
||||
const handleSave = useCallback(() => {
|
||||
if (!validateForm()) {
|
||||
// 페이지 상단으로 스크롤
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
return;
|
||||
}
|
||||
// 에러 초기화
|
||||
setValidationErrors({});
|
||||
setShowSaveDialog(true);
|
||||
}, []);
|
||||
}, [validateForm]);
|
||||
|
||||
const handleConfirmSave = useCallback(() => {
|
||||
console.log('저장:', formData);
|
||||
@@ -323,6 +359,35 @@ export function VendorDetail({ mode, vendorId }: VendorDetailProps) {
|
||||
/>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Validation 에러 표시 */}
|
||||
{Object.keys(validationErrors).length > 0 && (
|
||||
<Alert className="bg-red-50 border-red-200">
|
||||
<AlertDescription className="text-red-900">
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="text-lg">⚠️</span>
|
||||
<div className="flex-1">
|
||||
<strong className="block mb-2">
|
||||
입력 내용을 확인해주세요 ({Object.keys(validationErrors).length}개 오류)
|
||||
</strong>
|
||||
<ul className="space-y-1 text-sm">
|
||||
{Object.entries(validationErrors).map(([field, message]) => {
|
||||
const fieldName = FIELD_NAME_MAP[field] || field;
|
||||
return (
|
||||
<li key={field} className="flex items-start gap-1">
|
||||
<span>•</span>
|
||||
<span>
|
||||
<strong>{fieldName}</strong>: {message}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* 기본 정보 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
|
||||
@@ -21,6 +21,16 @@ import {
|
||||
FileText,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Alert, AlertDescription } from "../ui/alert";
|
||||
|
||||
// 필드명 매핑
|
||||
const FIELD_NAME_MAP: Record<string, string> = {
|
||||
name: "거래처명",
|
||||
businessNo: "사업자등록번호",
|
||||
representative: "대표자명",
|
||||
phone: "전화번호",
|
||||
email: "이메일",
|
||||
};
|
||||
import {
|
||||
ResponsiveFormTemplate,
|
||||
FormSection,
|
||||
@@ -117,10 +127,13 @@ export function ClientRegistration({
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!validateForm()) {
|
||||
toast.error("입력 내용을 확인해주세요.");
|
||||
// 페이지 상단으로 스크롤
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
return;
|
||||
}
|
||||
|
||||
// 에러 초기화
|
||||
setErrors({});
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await onSave(formData);
|
||||
@@ -163,6 +176,35 @@ export function ClientRegistration({
|
||||
saveDisabled={isSaving || isLoading}
|
||||
maxWidth="2xl"
|
||||
>
|
||||
{/* Validation 에러 표시 */}
|
||||
{Object.keys(errors).length > 0 && (
|
||||
<Alert className="bg-red-50 border-red-200 mb-6">
|
||||
<AlertDescription className="text-red-900">
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="text-lg">⚠️</span>
|
||||
<div className="flex-1">
|
||||
<strong className="block mb-2">
|
||||
입력 내용을 확인해주세요 ({Object.keys(errors).length}개 오류)
|
||||
</strong>
|
||||
<ul className="space-y-1 text-sm">
|
||||
{Object.entries(errors).map(([field, message]) => {
|
||||
const fieldName = FIELD_NAME_MAP[field] || field;
|
||||
return (
|
||||
<li key={field} className="flex items-start gap-1">
|
||||
<span>•</span>
|
||||
<span>
|
||||
<strong>{fieldName}</strong>: {message}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* 1. 기본 정보 */}
|
||||
<FormSection
|
||||
title="기본 정보"
|
||||
|
||||
323
src/components/material/ReceivingManagement/InspectionCreate.tsx
Normal file
323
src/components/material/ReceivingManagement/InspectionCreate.tsx
Normal file
@@ -0,0 +1,323 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 수입검사 등록 (IQC) 페이지
|
||||
* - 검사 대상 선택
|
||||
* - 검사 정보 입력 (검사일, 검사자*, LOT번호)
|
||||
* - 검사 항목 테이블 (겉모양, 두께, 폭, 길이)
|
||||
* - 종합 의견
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useMemo } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { ClipboardCheck, Calendar } 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 { PageLayout } from '@/components/organisms/PageLayout';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { mockInspectionTargets, defaultInspectionItems, generateLotNo } from './mockData';
|
||||
import type { InspectionCheckItem } from './types';
|
||||
import { SuccessDialog } from './SuccessDialog';
|
||||
|
||||
interface Props {
|
||||
id?: string; // 특정 발주건으로 바로 진입하는 경우
|
||||
}
|
||||
|
||||
export function InspectionCreate({ id }: Props) {
|
||||
const router = useRouter();
|
||||
|
||||
// 선택된 검사 대상
|
||||
const [selectedTargetId, setSelectedTargetId] = useState<string>(
|
||||
id || mockInspectionTargets[2]?.id || ''
|
||||
);
|
||||
|
||||
// 검사 정보
|
||||
const [inspectionDate, setInspectionDate] = useState(() => {
|
||||
const today = new Date();
|
||||
return today.toISOString().split('T')[0];
|
||||
});
|
||||
const [inspector, setInspector] = useState('');
|
||||
const [lotNo, setLotNo] = useState(() => generateLotNo());
|
||||
|
||||
// 검사 항목
|
||||
const [inspectionItems, setInspectionItems] = useState<InspectionCheckItem[]>(
|
||||
defaultInspectionItems.map((item) => ({ ...item }))
|
||||
);
|
||||
|
||||
// 종합 의견
|
||||
const [opinion, setOpinion] = useState('');
|
||||
|
||||
// 유효성 검사 에러
|
||||
const [validationErrors, setValidationErrors] = useState<string[]>([]);
|
||||
|
||||
// 성공 다이얼로그
|
||||
const [showSuccess, setShowSuccess] = useState(false);
|
||||
|
||||
// 선택된 대상 정보
|
||||
const selectedTarget = useMemo(() => {
|
||||
return mockInspectionTargets.find((t) => t.id === selectedTargetId);
|
||||
}, [selectedTargetId]);
|
||||
|
||||
// 대상 선택 핸들러
|
||||
const handleTargetSelect = useCallback((targetId: string) => {
|
||||
setSelectedTargetId(targetId);
|
||||
setValidationErrors([]);
|
||||
}, []);
|
||||
|
||||
// 판정 변경 핸들러
|
||||
const handleJudgmentChange = useCallback((itemId: string, judgment: '적' | '부적') => {
|
||||
setInspectionItems((prev) =>
|
||||
prev.map((item) => (item.id === itemId ? { ...item, judgment } : item))
|
||||
);
|
||||
setValidationErrors([]);
|
||||
}, []);
|
||||
|
||||
// 비고 변경 핸들러
|
||||
const handleRemarkChange = useCallback((itemId: string, remark: string) => {
|
||||
setInspectionItems((prev) =>
|
||||
prev.map((item) => (item.id === itemId ? { ...item, remark } : item))
|
||||
);
|
||||
}, []);
|
||||
|
||||
// 유효성 검사
|
||||
const validateForm = useCallback((): boolean => {
|
||||
const errors: string[] = [];
|
||||
|
||||
// 필수 필드: 검사자
|
||||
if (!inspector.trim()) {
|
||||
errors.push('검사자는 필수 입력 항목입니다.');
|
||||
}
|
||||
|
||||
// 검사 항목 판정 확인
|
||||
inspectionItems.forEach((item, index) => {
|
||||
if (!item.judgment) {
|
||||
errors.push(`${index + 1}. ${item.name}: 판정을 선택해주세요.`);
|
||||
}
|
||||
});
|
||||
|
||||
setValidationErrors(errors);
|
||||
return errors.length === 0;
|
||||
}, [inspector, inspectionItems]);
|
||||
|
||||
// 검사 저장
|
||||
const handleSubmit = useCallback(() => {
|
||||
if (!validateForm()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// TODO: API 호출
|
||||
console.log('검사 저장:', {
|
||||
targetId: selectedTargetId,
|
||||
inspectionDate,
|
||||
inspector,
|
||||
lotNo,
|
||||
items: inspectionItems,
|
||||
opinion,
|
||||
});
|
||||
|
||||
setShowSuccess(true);
|
||||
}, [validateForm, selectedTargetId, inspectionDate, inspector, lotNo, inspectionItems, opinion]);
|
||||
|
||||
// 취소 - 목록으로
|
||||
const handleCancel = useCallback(() => {
|
||||
router.push('/ko/material/receiving-management');
|
||||
}, [router]);
|
||||
|
||||
// 성공 다이얼로그 닫기
|
||||
const handleSuccessClose = useCallback(() => {
|
||||
setShowSuccess(false);
|
||||
router.push('/ko/material/receiving-management');
|
||||
}, [router]);
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
<div className="space-y-6">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<ClipboardCheck className="w-6 h-6" />
|
||||
<h1 className="text-xl font-semibold">수입검사 등록 (IQC)</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" onClick={handleCancel}>
|
||||
취소
|
||||
</Button>
|
||||
<Button onClick={handleSubmit}>
|
||||
<ClipboardCheck className="w-4 h-4 mr-1.5" />
|
||||
검사 저장
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
|
||||
{/* 좌측: 검사 대상 선택 */}
|
||||
<div className="lg:col-span-1 space-y-2">
|
||||
<Label className="text-sm font-medium">검사 대상 선택</Label>
|
||||
<div className="space-y-2 border rounded-lg p-2 bg-white">
|
||||
{mockInspectionTargets.map((target) => (
|
||||
<div
|
||||
key={target.id}
|
||||
onClick={() => handleTargetSelect(target.id)}
|
||||
className={`p-3 rounded-lg cursor-pointer border transition-colors ${
|
||||
selectedTargetId === target.id
|
||||
? 'bg-blue-50 border-blue-300'
|
||||
: 'bg-white border-gray-200 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<p className="font-medium text-sm">{target.orderNo}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{target.supplier} · {target.qty}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 우측: 검사 정보 및 항목 */}
|
||||
<div className="lg:col-span-3 space-y-6">
|
||||
{/* Validation 에러 표시 */}
|
||||
{validationErrors.length > 0 && (
|
||||
<Alert className="bg-red-50 border-red-200">
|
||||
<AlertDescription className="text-red-900">
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="text-lg">⚠️</span>
|
||||
<div className="flex-1">
|
||||
<strong className="block mb-2">
|
||||
입력 내용을 확인해주세요 ({validationErrors.length}개 오류)
|
||||
</strong>
|
||||
<ul className="space-y-1 text-sm">
|
||||
{validationErrors.map((error, index) => (
|
||||
<li key={index} className="flex items-start gap-1">
|
||||
<span>•</span>
|
||||
<span>{error}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* 검사 정보 */}
|
||||
<div className="space-y-4 bg-white p-4 rounded-lg border">
|
||||
<h3 className="font-medium">검사 정보</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm text-muted-foreground">검사일</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
type="date"
|
||||
value={inspectionDate}
|
||||
onChange={(e) => setInspectionDate(e.target.value)}
|
||||
className="pr-10"
|
||||
/>
|
||||
<Calendar className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground pointer-events-none" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm text-muted-foreground">
|
||||
검사자 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
value={inspector}
|
||||
onChange={(e) => {
|
||||
setInspector(e.target.value);
|
||||
setValidationErrors([]);
|
||||
}}
|
||||
placeholder="검사자명 입력"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm text-muted-foreground">LOT번호 (YYMMDD-##)</Label>
|
||||
<Input value={lotNo} onChange={(e) => setLotNo(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 검사 항목 */}
|
||||
<div className="space-y-4 bg-white p-4 rounded-lg border">
|
||||
<h3 className="font-medium">검사 항목</h3>
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left font-medium">검사항목</th>
|
||||
<th className="px-3 py-2 text-left font-medium">규격</th>
|
||||
<th className="px-3 py-2 text-left font-medium">검사방법</th>
|
||||
<th className="px-3 py-2 text-center font-medium w-[100px]">판정</th>
|
||||
<th className="px-3 py-2 text-left font-medium w-[120px]">비고</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{inspectionItems.map((item) => (
|
||||
<tr key={item.id} className="border-t">
|
||||
<td className="px-3 py-2">{item.name}</td>
|
||||
<td className="px-3 py-2 text-muted-foreground text-xs">
|
||||
{item.specification}
|
||||
</td>
|
||||
<td className="px-3 py-2">{item.method}</td>
|
||||
<td className="px-3 py-2">
|
||||
<Select
|
||||
value={item.judgment || ''}
|
||||
onValueChange={(value) =>
|
||||
handleJudgmentChange(item.id, value as '적' | '부적')
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-8">
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="적">적</SelectItem>
|
||||
<SelectItem value="부적">부적</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<Input
|
||||
value={item.remark || ''}
|
||||
onChange={(e) => handleRemarkChange(item.id, e.target.value)}
|
||||
placeholder="비고"
|
||||
className="h-8"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 종합 의견 */}
|
||||
<div className="space-y-2 bg-white p-4 rounded-lg border">
|
||||
<Label className="text-sm font-medium">종합 의견</Label>
|
||||
<Textarea
|
||||
value={opinion}
|
||||
onChange={(e) => setOpinion(e.target.value)}
|
||||
placeholder="검사 관련 특이사항 입력"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 성공 다이얼로그 */}
|
||||
<SuccessDialog
|
||||
open={showSuccess}
|
||||
type="inspection"
|
||||
lotNo={lotNo}
|
||||
onClose={handleSuccessClose}
|
||||
/>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
281
src/components/material/ReceivingManagement/ReceivingDetail.tsx
Normal file
281
src/components/material/ReceivingManagement/ReceivingDetail.tsx
Normal file
@@ -0,0 +1,281 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 입고 상세 페이지
|
||||
* 상태에 따라 다른 UI 표시:
|
||||
* - 검사대기: 입고증, 목록, 검사등록 버튼
|
||||
* - 배송중/발주완료: 목록, 입고처리 버튼 (입고증 없음)
|
||||
* - 입고대기: 목록 버튼만 (입고증 없음)
|
||||
* - 입고완료: 입고증, 목록 버튼
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useMemo } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Package, FileText, List, ClipboardCheck, Download } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { PageLayout } from '@/components/organisms/PageLayout';
|
||||
import { mockReceivingItems, mockReceivingDetails } from './mockData';
|
||||
import { RECEIVING_STATUS_LABELS, RECEIVING_STATUS_STYLES } from './types';
|
||||
import type { ReceivingDetail as ReceivingDetailType } from './types';
|
||||
import { ReceivingProcessDialog } from './ReceivingProcessDialog';
|
||||
import { ReceivingReceiptDialog } from './ReceivingReceiptDialog';
|
||||
import { SuccessDialog } from './SuccessDialog';
|
||||
|
||||
interface Props {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export function ReceivingDetail({ id }: Props) {
|
||||
const router = useRouter();
|
||||
const [isReceivingProcessDialogOpen, setIsReceivingProcessDialogOpen] = useState(false);
|
||||
const [isReceiptDialogOpen, setIsReceiptDialogOpen] = useState(false);
|
||||
const [successDialog, setSuccessDialog] = useState<{
|
||||
open: boolean;
|
||||
type: 'inspection' | 'receiving';
|
||||
lotNo?: string;
|
||||
}>({ open: false, type: 'receiving' });
|
||||
|
||||
// 데이터 가져오기
|
||||
const detail: ReceivingDetailType | undefined = useMemo(() => {
|
||||
// 먼저 상세 데이터에서 찾기
|
||||
if (mockReceivingDetails[id]) {
|
||||
return mockReceivingDetails[id];
|
||||
}
|
||||
// 없으면 목록에서 변환
|
||||
const item = mockReceivingItems.find((i) => i.id === id);
|
||||
if (item) {
|
||||
return {
|
||||
id: item.id,
|
||||
orderNo: item.orderNo,
|
||||
orderDate: undefined,
|
||||
supplier: item.supplier,
|
||||
itemCode: item.itemCode,
|
||||
itemName: item.itemName,
|
||||
specification: undefined,
|
||||
orderQty: item.orderQty,
|
||||
orderUnit: item.orderUnit,
|
||||
dueDate: undefined,
|
||||
status: item.status,
|
||||
receivingDate: undefined,
|
||||
receivingQty: item.receivingQty,
|
||||
receivingLot: item.lotNo,
|
||||
supplierLot: undefined,
|
||||
receivingLocation: undefined,
|
||||
receivingManager: undefined,
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
}, [id]);
|
||||
|
||||
// 목록으로 돌아가기
|
||||
const handleGoBack = useCallback(() => {
|
||||
router.push('/ko/material/receiving-management');
|
||||
}, [router]);
|
||||
|
||||
// 입고증 다이얼로그 열기
|
||||
const handleOpenReceipt = useCallback(() => {
|
||||
setIsReceiptDialogOpen(true);
|
||||
}, []);
|
||||
|
||||
// 검사등록 페이지로 이동
|
||||
const handleGoToInspection = useCallback(() => {
|
||||
router.push('/ko/material/receiving-management/inspection');
|
||||
}, [router]);
|
||||
|
||||
// 입고처리 다이얼로그 열기
|
||||
const handleOpenReceivingProcessDialog = useCallback(() => {
|
||||
setIsReceivingProcessDialogOpen(true);
|
||||
}, []);
|
||||
|
||||
// 입고 완료 처리
|
||||
const handleReceivingComplete = useCallback((lotNo: string) => {
|
||||
setIsReceivingProcessDialogOpen(false);
|
||||
setSuccessDialog({ open: true, type: 'receiving', lotNo });
|
||||
}, []);
|
||||
|
||||
// 성공 다이얼로그 닫기
|
||||
const handleSuccessDialogClose = useCallback(() => {
|
||||
setSuccessDialog({ open: false, type: 'receiving' });
|
||||
router.push('/ko/material/receiving-management');
|
||||
}, [router]);
|
||||
|
||||
if (!detail) {
|
||||
return (
|
||||
<PageLayout>
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<p className="text-muted-foreground">데이터를 찾을 수 없습니다.</p>
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
// 상태별 버튼 구성
|
||||
// 검사등록 버튼: 검사대기만
|
||||
const showInspectionButton = detail.status === 'inspection_pending';
|
||||
// 입고처리 버튼: 발주완료, 배송중만
|
||||
const showReceivingProcessButton =
|
||||
detail.status === 'order_completed' || detail.status === 'shipping';
|
||||
// 입고증 버튼: 검사대기, 입고완료만 (입고대기, 발주완료, 배송중은 없음)
|
||||
const showReceiptButton =
|
||||
detail.status === 'inspection_pending' || detail.status === 'completed';
|
||||
|
||||
// 입고 정보 표시 여부: 검사대기, 입고대기, 입고완료
|
||||
const showReceivingInfo =
|
||||
detail.status === 'inspection_pending' ||
|
||||
detail.status === 'receiving_pending' ||
|
||||
detail.status === 'completed';
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
<div className="space-y-6">
|
||||
{/* 헤더 */}
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Package className="w-6 h-6" />
|
||||
<h1 className="text-xl font-semibold">입고 상세</h1>
|
||||
<span className="text-lg text-muted-foreground">{detail.orderNo}</span>
|
||||
<Badge className={`${RECEIVING_STATUS_STYLES[detail.status]}`}>
|
||||
{RECEIVING_STATUS_LABELS[detail.status]}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{showReceiptButton && (
|
||||
<Button variant="outline" onClick={handleOpenReceipt}>
|
||||
<FileText className="w-4 h-4 mr-1.5" />
|
||||
입고증
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="outline" onClick={handleGoBack}>
|
||||
<List className="w-4 h-4 mr-1.5" />
|
||||
목록
|
||||
</Button>
|
||||
{showInspectionButton && (
|
||||
<Button onClick={handleGoToInspection}>
|
||||
<ClipboardCheck className="w-4 h-4 mr-1.5" />
|
||||
검사등록
|
||||
</Button>
|
||||
)}
|
||||
{showReceivingProcessButton && (
|
||||
<Button onClick={handleOpenReceivingProcessDialog}>
|
||||
<Download className="w-4 h-4 mr-1.5" />
|
||||
입고처리
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 발주 정보 */}
|
||||
<Card>
|
||||
<CardHeader className="pb-4">
|
||||
<CardTitle className="text-base">발주 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground mb-1">발주번호</p>
|
||||
<p className="font-medium">{detail.orderNo}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground mb-1">발주일자</p>
|
||||
<p className="font-medium">{detail.orderDate || '-'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground mb-1">공급업체</p>
|
||||
<p className="font-medium">{detail.supplier}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground mb-1">품목코드</p>
|
||||
<p className="font-medium">{detail.itemCode}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground mb-1">품목명</p>
|
||||
<p className="font-medium">{detail.itemName}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground mb-1">규격</p>
|
||||
<p className="font-medium">{detail.specification || '-'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground mb-1">발주수량</p>
|
||||
<p className="font-medium">
|
||||
{detail.orderQty} {detail.orderUnit}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground mb-1">납기일</p>
|
||||
<p className="font-medium">{detail.dueDate || '-'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 입고 정보 - 검사대기/입고대기/입고완료 상태에서만 표시 */}
|
||||
{showReceivingInfo && (
|
||||
<Card>
|
||||
<CardHeader className="pb-4">
|
||||
<CardTitle className="text-base">입고 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground mb-1">입고일자</p>
|
||||
<p className="font-medium">{detail.receivingDate || '-'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground mb-1">입고수량</p>
|
||||
<p className="font-medium">
|
||||
{detail.receivingQty !== undefined
|
||||
? `${detail.receivingQty} ${detail.orderUnit}`
|
||||
: '-'}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground mb-1">입고LOT</p>
|
||||
<p className="font-medium">{detail.receivingLot || '-'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground mb-1">공급업체LOT</p>
|
||||
<p className="font-medium">{detail.supplierLot || '-'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground mb-1">입고위치</p>
|
||||
<p className="font-medium">{detail.receivingLocation || '-'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground mb-1">입고담당</p>
|
||||
<p className="font-medium">{detail.receivingManager || '-'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 입고증 다이얼로그 */}
|
||||
<ReceivingReceiptDialog
|
||||
open={isReceiptDialogOpen}
|
||||
onOpenChange={setIsReceiptDialogOpen}
|
||||
detail={detail}
|
||||
/>
|
||||
|
||||
{/* 입고처리 다이얼로그 */}
|
||||
<ReceivingProcessDialog
|
||||
open={isReceivingProcessDialogOpen}
|
||||
onOpenChange={setIsReceivingProcessDialogOpen}
|
||||
detail={detail}
|
||||
onComplete={handleReceivingComplete}
|
||||
/>
|
||||
|
||||
{/* 성공 다이얼로그 */}
|
||||
<SuccessDialog
|
||||
open={successDialog.open}
|
||||
type={successDialog.type}
|
||||
lotNo={successDialog.lotNo}
|
||||
onClose={handleSuccessDialogClose}
|
||||
/>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
313
src/components/material/ReceivingManagement/ReceivingList.tsx
Normal file
313
src/components/material/ReceivingManagement/ReceivingList.tsx
Normal file
@@ -0,0 +1,313 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 입고 목록 페이지
|
||||
* IntegratedListTemplateV2 패턴 적용
|
||||
*/
|
||||
|
||||
import { useState, useMemo, useCallback } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import {
|
||||
Package,
|
||||
Truck,
|
||||
ClipboardCheck,
|
||||
Calendar,
|
||||
Eye,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { TableCell, TableRow } from '@/components/ui/table';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import {
|
||||
IntegratedListTemplateV2,
|
||||
TabOption,
|
||||
TableColumn,
|
||||
StatCard,
|
||||
} from '@/components/templates/IntegratedListTemplateV2';
|
||||
import { ListMobileCard, InfoField } from '@/components/organisms/ListMobileCard';
|
||||
import { mockReceivingItems, mockStats, mockFilterTabs } from './mockData';
|
||||
import { RECEIVING_STATUS_LABELS, RECEIVING_STATUS_STYLES } from './types';
|
||||
import type { ReceivingItem, ReceivingStatus } from './types';
|
||||
|
||||
// 페이지당 항목 수
|
||||
const ITEMS_PER_PAGE = 20;
|
||||
|
||||
export function ReceivingList() {
|
||||
const router = useRouter();
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set());
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [activeFilter, setActiveFilter] = useState<'all' | 'receiving_pending' | 'completed'>('all');
|
||||
|
||||
// 통계 카드
|
||||
const stats: StatCard[] = [
|
||||
{
|
||||
label: '입고대기',
|
||||
value: `${mockStats.receivingPendingCount}건`,
|
||||
icon: Package,
|
||||
iconColor: 'text-yellow-600',
|
||||
},
|
||||
{
|
||||
label: '배송중',
|
||||
value: `${mockStats.shippingCount}건`,
|
||||
icon: Truck,
|
||||
iconColor: 'text-blue-600',
|
||||
},
|
||||
{
|
||||
label: '검사대기',
|
||||
value: `${mockStats.inspectionPendingCount}건`,
|
||||
icon: ClipboardCheck,
|
||||
iconColor: 'text-orange-600',
|
||||
},
|
||||
{
|
||||
label: '금일입고',
|
||||
value: `${mockStats.todayReceivingCount}건`,
|
||||
icon: Calendar,
|
||||
iconColor: 'text-green-600',
|
||||
},
|
||||
];
|
||||
|
||||
// 테이블 컬럼
|
||||
const tableColumns: TableColumn[] = [
|
||||
{ key: 'no', label: '번호', className: 'w-[60px] text-center' },
|
||||
{ key: 'orderNo', label: '발주번호', className: 'min-w-[150px]' },
|
||||
{ key: 'itemCode', label: '품목코드', className: 'min-w-[130px]' },
|
||||
{ key: 'itemName', label: '품목명', className: 'min-w-[150px]' },
|
||||
{ key: 'supplier', label: '공급업체', className: 'min-w-[100px]' },
|
||||
{ key: 'orderQty', label: '발주수량', className: 'w-[100px] text-center' },
|
||||
{ key: 'receivingQty', label: '입고수량', className: 'w-[100px] text-center' },
|
||||
{ key: 'lotNo', label: 'LOT번호', className: 'w-[120px] text-center' },
|
||||
{ key: 'status', label: '상태', className: 'w-[100px] text-center' },
|
||||
];
|
||||
|
||||
// 필터링된 데이터
|
||||
const filteredResults = useMemo(() => {
|
||||
let result = [...mockReceivingItems];
|
||||
|
||||
// 탭 필터
|
||||
if (activeFilter === 'receiving_pending') {
|
||||
result = result.filter((item) =>
|
||||
['receiving_pending', 'inspection_pending'].includes(item.status)
|
||||
);
|
||||
} else if (activeFilter === 'completed') {
|
||||
result = result.filter((item) => item.status === 'completed');
|
||||
}
|
||||
|
||||
// 검색 필터
|
||||
if (searchTerm) {
|
||||
const term = searchTerm.toLowerCase();
|
||||
result = result.filter(
|
||||
(item) =>
|
||||
item.orderNo.toLowerCase().includes(term) ||
|
||||
item.itemCode.toLowerCase().includes(term) ||
|
||||
item.itemName.toLowerCase().includes(term) ||
|
||||
item.supplier.toLowerCase().includes(term)
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [searchTerm, activeFilter]);
|
||||
|
||||
// 페이지네이션 데이터
|
||||
const paginatedData = useMemo(() => {
|
||||
const startIndex = (currentPage - 1) * ITEMS_PER_PAGE;
|
||||
return filteredResults.slice(startIndex, startIndex + ITEMS_PER_PAGE);
|
||||
}, [filteredResults, currentPage]);
|
||||
|
||||
// 페이지네이션 설정
|
||||
const pagination = {
|
||||
currentPage,
|
||||
totalPages: Math.ceil(filteredResults.length / ITEMS_PER_PAGE),
|
||||
totalItems: filteredResults.length,
|
||||
itemsPerPage: ITEMS_PER_PAGE,
|
||||
onPageChange: setCurrentPage,
|
||||
};
|
||||
|
||||
// 선택 핸들러
|
||||
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((item) => item.id)));
|
||||
}
|
||||
}, [paginatedData, selectedItems.size]);
|
||||
|
||||
// 검색 변경 시 페이지 리셋
|
||||
const handleSearchChange = (value: string) => {
|
||||
setSearchTerm(value);
|
||||
setCurrentPage(1);
|
||||
};
|
||||
|
||||
// 탭 변경 시 페이지 리셋
|
||||
const handleTabChange = (value: string) => {
|
||||
setActiveFilter(value as 'all' | 'receiving_pending' | 'completed');
|
||||
setCurrentPage(1);
|
||||
setSelectedItems(new Set());
|
||||
};
|
||||
|
||||
// 상세 보기
|
||||
const handleView = useCallback(
|
||||
(id: string) => {
|
||||
router.push(`/ko/material/receiving-management/${id}`);
|
||||
},
|
||||
[router]
|
||||
);
|
||||
|
||||
// 테이블 행 렌더링
|
||||
const renderTableRow = (item: ReceivingItem, index: number, globalIndex: number) => {
|
||||
const isSelected = selectedItems.has(item.id);
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
key={item.id}
|
||||
className={`cursor-pointer hover:bg-muted/50 ${isSelected ? 'bg-blue-50' : ''}`}
|
||||
onClick={() => handleView(item.id)}
|
||||
>
|
||||
<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 className="font-medium">{item.orderNo}</TableCell>
|
||||
<TableCell>{item.itemCode}</TableCell>
|
||||
<TableCell className="max-w-[150px] truncate">{item.itemName}</TableCell>
|
||||
<TableCell>{item.supplier}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{item.orderQty} {item.orderUnit}
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{item.receivingQty !== undefined ? item.receivingQty : '-'}
|
||||
</TableCell>
|
||||
<TableCell className="text-center">{item.lotNo || '-'}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Badge className={`text-xs ${RECEIVING_STATUS_STYLES[item.status]}`}>
|
||||
{RECEIVING_STATUS_LABELS[item.status]}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
};
|
||||
|
||||
// 모바일 카드 렌더링
|
||||
const renderMobileCard = (
|
||||
item: ReceivingItem,
|
||||
index: number,
|
||||
globalIndex: number,
|
||||
isSelected: boolean,
|
||||
onToggle: () => void
|
||||
) => {
|
||||
return (
|
||||
<ListMobileCard
|
||||
id={item.id}
|
||||
isSelected={isSelected}
|
||||
onToggleSelection={onToggle}
|
||||
onClick={() => handleView(item.id)}
|
||||
headerBadges={
|
||||
<>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
#{globalIndex}
|
||||
</Badge>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{item.orderNo}
|
||||
</Badge>
|
||||
</>
|
||||
}
|
||||
title={item.itemName}
|
||||
statusBadge={
|
||||
<Badge className={`text-xs ${RECEIVING_STATUS_STYLES[item.status]}`}>
|
||||
{RECEIVING_STATUS_LABELS[item.status]}
|
||||
</Badge>
|
||||
}
|
||||
infoGrid={
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-3">
|
||||
<InfoField label="품목코드" value={item.itemCode} />
|
||||
<InfoField label="공급업체" value={item.supplier} />
|
||||
<InfoField label="발주수량" value={`${item.orderQty} ${item.orderUnit}`} />
|
||||
<InfoField
|
||||
label="입고수량"
|
||||
value={item.receivingQty !== undefined ? `${item.receivingQty}` : '-'}
|
||||
/>
|
||||
<InfoField label="LOT번호" value={item.lotNo || '-'} />
|
||||
</div>
|
||||
}
|
||||
actions={
|
||||
isSelected && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="flex-1"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleView(item.id);
|
||||
}}
|
||||
>
|
||||
<Eye className="w-4 h-4 mr-1" />
|
||||
상세
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
// 탭 옵션
|
||||
const tabs: TabOption[] = mockFilterTabs.map((tab) => ({
|
||||
value: tab.key,
|
||||
label: tab.label,
|
||||
count: tab.count,
|
||||
}));
|
||||
|
||||
// 하단 요약
|
||||
const tableFooter = (
|
||||
<TableRow className="bg-gray-50 hover:bg-gray-50">
|
||||
<TableCell colSpan={tableColumns.length + 1} className="py-3">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
총 {filteredResults.length}건 / 입고대기 {mockStats.receivingPendingCount}건 / 검사대기{' '}
|
||||
{mockStats.inspectionPendingCount}건
|
||||
</span>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
|
||||
return (
|
||||
<IntegratedListTemplateV2<ReceivingItem>
|
||||
title="입고 목록"
|
||||
icon={Package}
|
||||
stats={stats}
|
||||
searchValue={searchTerm}
|
||||
onSearchChange={handleSearchChange}
|
||||
searchPlaceholder="발주번호, 품목코드, 품목명, 공급업체 검색..."
|
||||
tabs={tabs}
|
||||
activeTab={activeFilter}
|
||||
onTabChange={handleTabChange}
|
||||
tableColumns={tableColumns}
|
||||
data={paginatedData}
|
||||
totalCount={filteredResults.length}
|
||||
allData={filteredResults}
|
||||
selectedItems={selectedItems}
|
||||
onToggleSelection={handleToggleSelection}
|
||||
onToggleSelectAll={handleToggleSelectAll}
|
||||
getItemId={(item) => item.id}
|
||||
renderTableRow={renderTableRow}
|
||||
renderMobileCard={renderMobileCard}
|
||||
pagination={pagination}
|
||||
tableFooter={tableFooter}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 입고처리 다이얼로그
|
||||
* - 발주 정보 표시
|
||||
* - 입고LOT*, 공급업체LOT, 입고수량*, 입고위치* 입력
|
||||
* - 비고 입력
|
||||
*/
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import { Download } 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 {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { generateLotNo } from './mockData';
|
||||
import type { ReceivingDetail } from './types';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
detail: ReceivingDetail;
|
||||
onComplete: (lotNo: string) => void;
|
||||
}
|
||||
|
||||
export function ReceivingProcessDialog({ open, onOpenChange, detail, onComplete }: Props) {
|
||||
// 폼 데이터
|
||||
const [receivingLot, setReceivingLot] = useState(() => generateLotNo());
|
||||
const [supplierLot, setSupplierLot] = useState('');
|
||||
const [receivingQty, setReceivingQty] = useState<string>(detail.orderQty.toString());
|
||||
const [receivingLocation, setReceivingLocation] = useState('');
|
||||
const [remark, setRemark] = useState('');
|
||||
|
||||
// 유효성 검사 에러
|
||||
const [validationErrors, setValidationErrors] = useState<string[]>([]);
|
||||
|
||||
// 유효성 검사
|
||||
const validateForm = useCallback((): boolean => {
|
||||
const errors: string[] = [];
|
||||
|
||||
if (!receivingLot.trim()) {
|
||||
errors.push('입고LOT는 필수 입력 항목입니다.');
|
||||
}
|
||||
|
||||
if (!receivingQty.trim() || isNaN(Number(receivingQty)) || Number(receivingQty) <= 0) {
|
||||
errors.push('입고수량은 필수 입력 항목입니다. 유효한 숫자를 입력해주세요.');
|
||||
}
|
||||
|
||||
if (!receivingLocation.trim()) {
|
||||
errors.push('입고위치는 필수 입력 항목입니다.');
|
||||
}
|
||||
|
||||
setValidationErrors(errors);
|
||||
return errors.length === 0;
|
||||
}, [receivingLot, receivingQty, receivingLocation]);
|
||||
|
||||
// 입고 처리
|
||||
const handleSubmit = useCallback(() => {
|
||||
if (!validateForm()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// TODO: API 호출
|
||||
console.log('입고 처리:', {
|
||||
detailId: detail.id,
|
||||
receivingLot,
|
||||
supplierLot,
|
||||
receivingQty: Number(receivingQty),
|
||||
receivingLocation,
|
||||
remark,
|
||||
});
|
||||
|
||||
onComplete(receivingLot);
|
||||
}, [validateForm, detail.id, receivingLot, supplierLot, receivingQty, receivingLocation, remark, onComplete]);
|
||||
|
||||
// 취소
|
||||
const handleCancel = useCallback(() => {
|
||||
onOpenChange(false);
|
||||
}, [onOpenChange]);
|
||||
|
||||
// 다이얼로그 닫힐 때 상태 초기화
|
||||
const handleOpenChange = useCallback(
|
||||
(newOpen: boolean) => {
|
||||
if (!newOpen) {
|
||||
setValidationErrors([]);
|
||||
}
|
||||
onOpenChange(newOpen);
|
||||
},
|
||||
[onOpenChange]
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>입고 처리</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6 mt-4">
|
||||
{/* 발주 정보 요약 */}
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-muted-foreground">발주번호:</span>{' '}
|
||||
<span className="font-medium">{detail.orderNo}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">공급업체:</span>{' '}
|
||||
<span className="font-medium">{detail.supplier}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">품목:</span>{' '}
|
||||
<span className="font-medium">{detail.itemName}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">발주수량:</span>{' '}
|
||||
<span className="font-medium">{detail.orderQty} {detail.orderUnit}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Validation 에러 표시 */}
|
||||
{validationErrors.length > 0 && (
|
||||
<Alert className="bg-red-50 border-red-200">
|
||||
<AlertDescription className="text-red-900">
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="text-lg">⚠️</span>
|
||||
<div className="flex-1">
|
||||
<strong className="block mb-2">
|
||||
입력 내용을 확인해주세요 ({validationErrors.length}개 오류)
|
||||
</strong>
|
||||
<ul className="space-y-1 text-sm">
|
||||
{validationErrors.map((error, index) => (
|
||||
<li key={index} className="flex items-start gap-1">
|
||||
<span>•</span>
|
||||
<span>{error}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* 입력 필드 */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm">
|
||||
입고LOT <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
value={receivingLot}
|
||||
onChange={(e) => {
|
||||
setReceivingLot(e.target.value);
|
||||
setValidationErrors([]);
|
||||
}}
|
||||
placeholder="예: 251223-41"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm text-muted-foreground">공급업체LOT</Label>
|
||||
<Input
|
||||
value={supplierLot}
|
||||
onChange={(e) => setSupplierLot(e.target.value)}
|
||||
placeholder="예: 2402944"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm">
|
||||
입고수량 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={receivingQty}
|
||||
onChange={(e) => {
|
||||
setReceivingQty(e.target.value);
|
||||
setValidationErrors([]);
|
||||
}}
|
||||
placeholder="수량 입력"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm">
|
||||
입고위치 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
value={receivingLocation}
|
||||
onChange={(e) => {
|
||||
setReceivingLocation(e.target.value);
|
||||
setValidationErrors([]);
|
||||
}}
|
||||
placeholder="예: A-01"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 비고 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm text-muted-foreground">비고</Label>
|
||||
<Textarea
|
||||
value={remark}
|
||||
onChange={(e) => setRemark(e.target.value)}
|
||||
placeholder="특이사항 입력"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 버튼 영역 */}
|
||||
<div className="flex justify-end gap-2 mt-6 pt-4 border-t">
|
||||
<Button variant="outline" onClick={handleCancel}>
|
||||
취소
|
||||
</Button>
|
||||
<Button onClick={handleSubmit}>
|
||||
입고 처리
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 입고증 다이얼로그 (인쇄용)
|
||||
* - 작업일지(WorkLogModal) 스타일 적용
|
||||
*/
|
||||
|
||||
import { Printer, Download, X } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { VisuallyHidden } from '@radix-ui/react-visually-hidden';
|
||||
import type { ReceivingDetail } from './types';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
detail: ReceivingDetail;
|
||||
}
|
||||
|
||||
export function ReceivingReceiptDialog({ open, onOpenChange, detail }: Props) {
|
||||
const handlePrint = () => {
|
||||
window.print();
|
||||
};
|
||||
|
||||
const handleDownload = () => {
|
||||
// TODO: PDF 다운로드 기능
|
||||
console.log('PDF 다운로드:', detail);
|
||||
};
|
||||
|
||||
const today = new Date();
|
||||
const formattedDate = `${today.getFullYear()}년 ${today.getMonth() + 1}월 ${today.getDate()}일`;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-4xl max-h-[90vh] overflow-y-auto p-0 gap-0 bg-gray-100">
|
||||
{/* 접근성을 위한 숨겨진 타이틀 */}
|
||||
<VisuallyHidden>
|
||||
<DialogTitle>입고증 - {detail.orderNo}</DialogTitle>
|
||||
</VisuallyHidden>
|
||||
|
||||
{/* 모달 헤더 - 작업일지 스타일 */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b bg-white sticky top-0 z-10">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="font-semibold text-lg">입고증</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{detail.supplier}
|
||||
</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
({detail.orderNo})
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={handlePrint}>
|
||||
<Printer className="w-4 h-4 mr-1.5" />
|
||||
인쇄
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={handleDownload}>
|
||||
<Download className="w-4 h-4 mr-1.5" />
|
||||
다운로드
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 문서 본문 */}
|
||||
<div className="m-6 p-8 bg-white rounded-lg shadow-sm print:m-0 print:shadow-none">
|
||||
{/* 제목 */}
|
||||
<div className="text-center mb-8">
|
||||
<h2 className="text-2xl font-bold">입고증</h2>
|
||||
<p className="text-sm text-muted-foreground">RECEIVING SLIP</p>
|
||||
</div>
|
||||
|
||||
{/* 입고 정보 / 공급업체 정보 */}
|
||||
<div className="grid grid-cols-2 gap-8 mb-8">
|
||||
{/* 입고 정보 */}
|
||||
<div className="space-y-3 text-sm">
|
||||
<h3 className="font-semibold border-b pb-2">입고 정보</h3>
|
||||
<div className="grid grid-cols-[100px_1fr] gap-y-2">
|
||||
<span className="text-muted-foreground">입고번호</span>
|
||||
<span className="font-medium">{detail.orderNo}</span>
|
||||
<span className="text-muted-foreground">입고일자</span>
|
||||
<span>{detail.receivingDate || today.toISOString().split('T')[0]}</span>
|
||||
<span className="text-muted-foreground">발주번호</span>
|
||||
<span>{detail.orderNo}</span>
|
||||
<span className="text-muted-foreground">입고LOT</span>
|
||||
<span>{detail.receivingLot || '-'}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 공급업체 정보 */}
|
||||
<div className="space-y-3 text-sm">
|
||||
<h3 className="font-semibold border-b pb-2">공급업체 정보</h3>
|
||||
<div className="grid grid-cols-[100px_1fr] gap-y-2">
|
||||
<span className="text-muted-foreground">업체명</span>
|
||||
<span className="font-medium">{detail.supplier}</span>
|
||||
<span className="text-muted-foreground">공급업체LOT</span>
|
||||
<span>{detail.supplierLot || '-'}</span>
|
||||
<span className="text-muted-foreground">담당자</span>
|
||||
<span>{detail.receivingManager || '-'}</span>
|
||||
<span className="text-muted-foreground">입고위치</span>
|
||||
<span>{detail.receivingLocation || '-'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 입고 품목 상세 */}
|
||||
<div className="mb-8">
|
||||
<h3 className="font-semibold text-sm mb-3">입고 품목 상세</h3>
|
||||
<table className="w-full text-sm border-collapse">
|
||||
<thead>
|
||||
<tr className="border-y bg-gray-50">
|
||||
<th className="px-3 py-2 text-center font-medium w-12">No</th>
|
||||
<th className="px-3 py-2 text-left font-medium">품목코드</th>
|
||||
<th className="px-3 py-2 text-left font-medium">품목명</th>
|
||||
<th className="px-3 py-2 text-left font-medium">규격</th>
|
||||
<th className="px-3 py-2 text-center font-medium w-24">발주수량</th>
|
||||
<th className="px-3 py-2 text-center font-medium w-24">입고수량</th>
|
||||
<th className="px-3 py-2 text-center font-medium w-16">단위</th>
|
||||
<th className="px-3 py-2 text-left font-medium w-24">비고</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr className="border-b">
|
||||
<td className="px-3 py-2 text-center">1</td>
|
||||
<td className="px-3 py-2">{detail.itemCode}</td>
|
||||
<td className="px-3 py-2">{detail.itemName}</td>
|
||||
<td className="px-3 py-2">{detail.specification || '-'}</td>
|
||||
<td className="px-3 py-2 text-center">{detail.orderQty}</td>
|
||||
<td className="px-3 py-2 text-center">{detail.receivingQty || '-'}</td>
|
||||
<td className="px-3 py-2 text-center">{detail.orderUnit}</td>
|
||||
<td className="px-3 py-2">-</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* 수입검사 안내 */}
|
||||
<div className="mb-8 p-4 bg-gray-50 rounded-lg text-sm">
|
||||
<p className="flex items-start gap-2">
|
||||
<span className="font-medium">📋 수입검사 안내</span>
|
||||
</p>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
본 입고건에 대한 <span className="font-medium text-blue-600">수입검사(IQC)</span>가 필요합니다.<br />
|
||||
품질관리 > 수입검사(IQC) 메뉴에서 검사를 진행해주세요.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 서명란 */}
|
||||
<div className="grid grid-cols-3 gap-6 mb-8">
|
||||
<div className="border rounded p-4 text-center">
|
||||
<p className="text-sm font-medium mb-12">입고담당</p>
|
||||
<p className="text-xs text-muted-foreground">(인)</p>
|
||||
</div>
|
||||
<div className="border rounded p-4 text-center">
|
||||
<p className="text-sm font-medium mb-12">품질검사</p>
|
||||
<p className="text-xs text-muted-foreground">(인)</p>
|
||||
</div>
|
||||
<div className="border rounded p-4 text-center">
|
||||
<p className="text-sm font-medium mb-12">창고담당</p>
|
||||
<p className="text-xs text-muted-foreground">(인)</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 발행일 / 회사명 */}
|
||||
<div className="text-right text-sm text-muted-foreground">
|
||||
<p>발행일: {formattedDate}</p>
|
||||
<p>(주) 코드빌더스</p>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 성공 다이얼로그 (검사 등록 완료 / 입고 처리 완료)
|
||||
*/
|
||||
|
||||
import { CheckCircle2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
} from '@/components/ui/dialog';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
type: 'inspection' | 'receiving';
|
||||
lotNo?: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function SuccessDialog({ open, type, lotNo, onClose }: Props) {
|
||||
const title = type === 'inspection' ? '수입검사가 합격 처리되었습니다.' : '입고 처리가 완료되었습니다.';
|
||||
const message = type === 'inspection'
|
||||
? `LOT번호: ${lotNo}\n입고 처리가 완료되었습니다.`
|
||||
: `LOT번호: ${lotNo}\n재고에 반영되었습니다.`;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(newOpen) => !newOpen && onClose()}>
|
||||
<DialogContent className="max-w-sm">
|
||||
<div className="flex flex-col items-center text-center py-6 space-y-4">
|
||||
<div className="w-16 h-16 rounded-full bg-green-100 flex items-center justify-center">
|
||||
<CheckCircle2 className="w-10 h-10 text-green-600" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-lg font-semibold">{title}</h3>
|
||||
<div className="text-sm text-muted-foreground whitespace-pre-line">
|
||||
{message}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button onClick={onClose} className="w-full mt-4">
|
||||
확인
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
11
src/components/material/ReceivingManagement/index.ts
Normal file
11
src/components/material/ReceivingManagement/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* 입고관리 컴포넌트 내보내기
|
||||
*/
|
||||
|
||||
export { ReceivingList } from './ReceivingList';
|
||||
export { ReceivingDetail } from './ReceivingDetail';
|
||||
export { InspectionCreate } from './InspectionCreate';
|
||||
export { ReceivingProcessDialog } from './ReceivingProcessDialog';
|
||||
export { ReceivingReceiptDialog } from './ReceivingReceiptDialog';
|
||||
export { SuccessDialog } from './SuccessDialog';
|
||||
export * from './types';
|
||||
158
src/components/material/ReceivingManagement/mockData.ts
Normal file
158
src/components/material/ReceivingManagement/mockData.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* 입고관리 목업 데이터
|
||||
*/
|
||||
|
||||
import type {
|
||||
ReceivingItem,
|
||||
ReceivingDetail,
|
||||
ReceivingStats,
|
||||
FilterTab,
|
||||
InspectionTarget,
|
||||
InspectionCheckItem,
|
||||
} from './types';
|
||||
|
||||
// 목업 입고 목록
|
||||
export const mockReceivingItems: ReceivingItem[] = [
|
||||
{
|
||||
id: '1',
|
||||
orderNo: 'KD-SO-250312-001',
|
||||
itemCode: 'RM-COIL-08',
|
||||
itemName: '갈바코일 0.8T',
|
||||
supplier: '포항철강',
|
||||
orderQty: 5,
|
||||
orderUnit: '톤',
|
||||
receivingQty: undefined,
|
||||
lotNo: undefined,
|
||||
status: 'inspection_pending',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
orderNo: 'KD-SO-250311-001',
|
||||
itemCode: 'PP-ENDLOCK-01',
|
||||
itemName: '엔드락 2100',
|
||||
supplier: '부품산업',
|
||||
orderQty: 200,
|
||||
orderUnit: 'EA',
|
||||
receivingQty: undefined,
|
||||
lotNo: undefined,
|
||||
status: 'receiving_pending',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
orderNo: 'KD-SO-250310-001',
|
||||
itemCode: 'RM-SCREEN-01',
|
||||
itemName: '방충망 원단 1016',
|
||||
supplier: '한국망사',
|
||||
orderQty: 100,
|
||||
orderUnit: 'M',
|
||||
receivingQty: undefined,
|
||||
lotNo: undefined,
|
||||
status: 'inspection_pending',
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
orderNo: 'KD-SO-250302-001',
|
||||
itemCode: 'PP-MOTOR-01',
|
||||
itemName: '튜블러모터',
|
||||
supplier: '모터공사',
|
||||
orderQty: 50,
|
||||
orderUnit: 'EA',
|
||||
receivingQty: undefined,
|
||||
lotNo: undefined,
|
||||
status: 'order_completed',
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
orderNo: 'KD-SO-250301-001',
|
||||
itemCode: 'RM-COIL-05',
|
||||
itemName: '갈바코일 0.5T',
|
||||
supplier: '철강공업',
|
||||
orderQty: 10,
|
||||
orderUnit: '톤',
|
||||
receivingQty: undefined,
|
||||
lotNo: undefined,
|
||||
status: 'shipping',
|
||||
},
|
||||
];
|
||||
|
||||
// 상세 정보 목업
|
||||
export const mockReceivingDetails: Record<string, ReceivingDetail> = {
|
||||
'1': {
|
||||
id: '1',
|
||||
orderNo: 'KD-SO-250312-001',
|
||||
orderDate: undefined,
|
||||
supplier: '포항철강',
|
||||
itemCode: 'RM-COIL-08',
|
||||
itemName: '갈바코일 0.8T',
|
||||
specification: undefined,
|
||||
orderQty: 5,
|
||||
orderUnit: '톤',
|
||||
dueDate: undefined,
|
||||
status: 'inspection_pending',
|
||||
receivingDate: undefined,
|
||||
receivingQty: undefined,
|
||||
receivingLot: undefined,
|
||||
supplierLot: undefined,
|
||||
receivingLocation: undefined,
|
||||
receivingManager: '자재팀',
|
||||
},
|
||||
'5': {
|
||||
id: '5',
|
||||
orderNo: 'KD-SO-250301-001',
|
||||
orderDate: undefined,
|
||||
supplier: '철강공업',
|
||||
itemCode: 'RM-COIL-05',
|
||||
itemName: '갈바코일 0.5T',
|
||||
specification: undefined,
|
||||
orderQty: 10,
|
||||
orderUnit: '톤',
|
||||
dueDate: undefined,
|
||||
status: 'shipping',
|
||||
receivingDate: undefined,
|
||||
receivingQty: undefined,
|
||||
receivingLot: undefined,
|
||||
supplierLot: undefined,
|
||||
receivingLocation: undefined,
|
||||
receivingManager: undefined,
|
||||
},
|
||||
};
|
||||
|
||||
// 통계 목업
|
||||
export const mockStats: ReceivingStats = {
|
||||
receivingPendingCount: 2,
|
||||
shippingCount: 1,
|
||||
inspectionPendingCount: 2,
|
||||
todayReceivingCount: 0,
|
||||
};
|
||||
|
||||
// 필터 탭 목업
|
||||
export const mockFilterTabs: FilterTab[] = [
|
||||
{ key: 'all', label: '전체', count: 5 },
|
||||
{ key: 'receiving_pending', label: '입고대기', count: 2 },
|
||||
{ key: 'completed', label: '입고완료', count: 2 },
|
||||
];
|
||||
|
||||
// 검사 대상 목업
|
||||
export const mockInspectionTargets: InspectionTarget[] = [
|
||||
{ id: '3', orderNo: 'KD-SO-250310-001', supplier: '한국망사', qty: '100EA' },
|
||||
{ id: '2', orderNo: 'KD-SO-250311-001', supplier: '부품산업', qty: '200EA' },
|
||||
{ id: '1', orderNo: 'KD-SO-250312-001', supplier: '포항철강', qty: '5EA' },
|
||||
];
|
||||
|
||||
// 검사 항목 템플릿
|
||||
export const defaultInspectionItems: InspectionCheckItem[] = [
|
||||
{ id: '1', name: '겉모양', specification: '사용상 해로운 결함이 없을 것', method: '육안검사', judgment: '', remark: '' },
|
||||
{ id: '2', name: '두께', specification: '규격 참조', method: '마이크로미터', judgment: '', remark: '' },
|
||||
{ id: '3', name: '폭', specification: '규격 참조', method: '줄자', judgment: '', remark: '' },
|
||||
{ id: '4', name: '길이', specification: '규격 참조', method: '줄자', judgment: '', remark: '' },
|
||||
];
|
||||
|
||||
// LOT 번호 생성
|
||||
export function generateLotNo(): string {
|
||||
const now = new Date();
|
||||
const year = now.getFullYear().toString().slice(-2);
|
||||
const month = (now.getMonth() + 1).toString().padStart(2, '0');
|
||||
const day = now.getDate().toString().padStart(2, '0');
|
||||
const seq = Math.floor(Math.random() * 100).toString().padStart(2, '0');
|
||||
return `${year}${month}${day}-${seq}`;
|
||||
}
|
||||
117
src/components/material/ReceivingManagement/types.ts
Normal file
117
src/components/material/ReceivingManagement/types.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* 입고관리 타입 정의
|
||||
*/
|
||||
|
||||
// 입고 상태
|
||||
export type ReceivingStatus =
|
||||
| 'order_completed' // 발주완료
|
||||
| 'shipping' // 배송중
|
||||
| 'inspection_pending' // 검사대기
|
||||
| 'receiving_pending' // 입고대기
|
||||
| 'completed'; // 입고완료
|
||||
|
||||
// 상태 라벨
|
||||
export const RECEIVING_STATUS_LABELS: Record<ReceivingStatus, string> = {
|
||||
order_completed: '발주완료',
|
||||
shipping: '배송중',
|
||||
inspection_pending: '검사대기',
|
||||
receiving_pending: '입고대기',
|
||||
completed: '입고완료',
|
||||
};
|
||||
|
||||
// 상태 스타일
|
||||
export const RECEIVING_STATUS_STYLES: Record<ReceivingStatus, string> = {
|
||||
order_completed: 'bg-gray-100 text-gray-800',
|
||||
shipping: 'bg-blue-100 text-blue-800',
|
||||
inspection_pending: 'bg-orange-100 text-orange-800',
|
||||
receiving_pending: 'bg-yellow-100 text-yellow-800',
|
||||
completed: 'bg-green-100 text-green-800',
|
||||
};
|
||||
|
||||
// 입고 목록 아이템
|
||||
export interface ReceivingItem {
|
||||
id: string;
|
||||
orderNo: string; // 발주번호
|
||||
itemCode: string; // 품목코드
|
||||
itemName: string; // 품목명
|
||||
supplier: string; // 공급업체
|
||||
orderQty: number; // 발주수량
|
||||
orderUnit: string; // 발주단위
|
||||
receivingQty?: number; // 입고수량
|
||||
lotNo?: string; // LOT번호
|
||||
status: ReceivingStatus; // 상태
|
||||
}
|
||||
|
||||
// 입고 상세 정보
|
||||
export interface ReceivingDetail {
|
||||
id: string;
|
||||
orderNo: string; // 발주번호
|
||||
orderDate?: string; // 발주일자
|
||||
supplier: string; // 공급업체
|
||||
itemCode: string; // 품목코드
|
||||
itemName: string; // 품목명
|
||||
specification?: string; // 규격
|
||||
orderQty: number; // 발주수량
|
||||
orderUnit: string; // 발주단위
|
||||
dueDate?: string; // 납기일
|
||||
status: ReceivingStatus;
|
||||
// 입고 정보
|
||||
receivingDate?: string; // 입고일자
|
||||
receivingQty?: number; // 입고수량
|
||||
receivingLot?: string; // 입고LOT
|
||||
supplierLot?: string; // 공급업체LOT
|
||||
receivingLocation?: string; // 입고위치
|
||||
receivingManager?: string; // 입고담당
|
||||
}
|
||||
|
||||
// 검사 대상 아이템
|
||||
export interface InspectionTarget {
|
||||
id: string;
|
||||
orderNo: string;
|
||||
supplier: string;
|
||||
qty: string;
|
||||
}
|
||||
|
||||
// 검사 항목
|
||||
export interface InspectionCheckItem {
|
||||
id: string;
|
||||
name: string; // 검사항목
|
||||
specification: string; // 규격
|
||||
method: string; // 검사방법
|
||||
judgment: '적' | '부적' | ''; // 판정
|
||||
remark?: string; // 비고
|
||||
}
|
||||
|
||||
// 검사 등록 폼 데이터
|
||||
export interface InspectionFormData {
|
||||
targetId: string; // 검사 대상 ID
|
||||
inspectionDate: string; // 검사일
|
||||
inspector: string; // 검사자
|
||||
lotNo: string; // LOT번호
|
||||
items: InspectionCheckItem[];
|
||||
opinion?: string; // 종합 의견
|
||||
}
|
||||
|
||||
// 입고처리 폼 데이터
|
||||
export interface ReceivingProcessFormData {
|
||||
receivingLot: string; // 입고LOT *
|
||||
supplierLot?: string; // 공급업체LOT
|
||||
receivingQty: number; // 입고수량 *
|
||||
receivingLocation: string; // 입고위치 *
|
||||
remark?: string; // 비고
|
||||
}
|
||||
|
||||
// 통계 데이터
|
||||
export interface ReceivingStats {
|
||||
receivingPendingCount: number; // 입고대기
|
||||
shippingCount: number; // 배송중
|
||||
inspectionPendingCount: number; // 검사대기
|
||||
todayReceivingCount: number; // 금일입고
|
||||
}
|
||||
|
||||
// 필터 탭
|
||||
export interface FilterTab {
|
||||
key: 'all' | 'receiving_pending' | 'completed';
|
||||
label: string;
|
||||
count: number;
|
||||
}
|
||||
262
src/components/material/StockStatus/StockStatusDetail.tsx
Normal file
262
src/components/material/StockStatus/StockStatusDetail.tsx
Normal file
@@ -0,0 +1,262 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 재고현황 상세 페이지
|
||||
*/
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Package, AlertCircle, ArrowLeft } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { PageLayout } from '@/components/organisms/PageLayout';
|
||||
import { getStockDetail } from './mockData';
|
||||
import {
|
||||
ITEM_TYPE_LABELS,
|
||||
ITEM_TYPE_STYLES,
|
||||
STOCK_STATUS_LABELS,
|
||||
LOT_STATUS_LABELS,
|
||||
} from './types';
|
||||
import type { StockDetail, LotDetail } from './types';
|
||||
|
||||
interface StockStatusDetailProps {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export function StockStatusDetail({ id }: StockStatusDetailProps) {
|
||||
const router = useRouter();
|
||||
|
||||
// Mock 데이터에서 상세 정보 가져오기 (동적 생성)
|
||||
const detail: StockDetail | undefined = getStockDetail(id);
|
||||
|
||||
// 가장 오래된 LOT 찾기 (FIFO 권장용)
|
||||
const oldestLot = useMemo(() => {
|
||||
if (!detail || detail.lots.length === 0) return null;
|
||||
return detail.lots.reduce((oldest, lot) =>
|
||||
lot.daysElapsed > oldest.daysElapsed ? lot : oldest
|
||||
);
|
||||
}, [detail]);
|
||||
|
||||
// 총 수량 계산
|
||||
const totalQty = useMemo(() => {
|
||||
if (!detail) return 0;
|
||||
return detail.lots.reduce((sum, lot) => sum + lot.qty, 0);
|
||||
}, [detail]);
|
||||
|
||||
// 목록으로 돌아가기
|
||||
const handleGoBack = () => {
|
||||
router.push('/ko/material/stock-status');
|
||||
};
|
||||
|
||||
if (!detail) {
|
||||
return (
|
||||
<PageLayout>
|
||||
<div className="space-y-6">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Package className="w-6 h-6" />
|
||||
<h1 className="text-xl font-semibold">재고 상세</h1>
|
||||
</div>
|
||||
<Button variant="outline" onClick={handleGoBack}>
|
||||
목록
|
||||
</Button>
|
||||
</div>
|
||||
<Card>
|
||||
<CardContent className="py-12 text-center text-muted-foreground">
|
||||
재고 정보를 찾을 수 없습니다.
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
<div className="space-y-6">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Package className="w-6 h-6" />
|
||||
<h1 className="text-xl font-semibold">재고 상세</h1>
|
||||
<span className="text-muted-foreground">{detail.itemCode}</span>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{STOCK_STATUS_LABELS[detail.status]}
|
||||
</Badge>
|
||||
</div>
|
||||
<Button variant="outline" onClick={handleGoBack}>
|
||||
목록
|
||||
</Button>
|
||||
</div>
|
||||
{/* 기본 정보 */}
|
||||
<Card>
|
||||
<CardHeader className="pb-4">
|
||||
<CardTitle className="text-base font-medium">기본 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground mb-1">품목코드</div>
|
||||
<div className="font-medium">{detail.itemCode}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground mb-1">품목명</div>
|
||||
<div className="font-medium">{detail.itemName}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground mb-1">품목유형</div>
|
||||
<Badge className={`text-xs ${ITEM_TYPE_STYLES[detail.itemType]}`}>
|
||||
{ITEM_TYPE_LABELS[detail.itemType]}
|
||||
</Badge>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground mb-1">카테고리</div>
|
||||
<div className="font-medium">{detail.category}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground mb-1">규격</div>
|
||||
<div className="font-medium">{detail.specification || '-'}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground mb-1">단위</div>
|
||||
<div className="font-medium">{detail.unit}</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 재고 현황 */}
|
||||
<Card>
|
||||
<CardHeader className="pb-4">
|
||||
<CardTitle className="text-base font-medium">재고 현황</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-6">
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground mb-1">현재 재고량</div>
|
||||
<div className="text-2xl font-bold">
|
||||
{detail.currentStock} <span className="text-base font-normal">{detail.unit}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground mb-1">안전 재고</div>
|
||||
<div className="text-lg font-medium">
|
||||
{detail.safetyStock} <span className="text-sm font-normal">{detail.unit}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground mb-1">재고 위치</div>
|
||||
<div className="font-medium">{detail.location}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground mb-1">LOT 개수</div>
|
||||
<div className="font-medium">{detail.lotCount}개</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground mb-1">최근 입고일</div>
|
||||
<div className="font-medium">{detail.lastReceiptDate}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground mb-1">재고 상태</div>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{STOCK_STATUS_LABELS[detail.status]}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* LOT별 상세 재고 */}
|
||||
<Card>
|
||||
<CardHeader className="pb-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-base font-medium">LOT별 상세 재고</CardTitle>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
FIFO 순서 · 오래된 LOT부터 사용 권장
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-muted/50">
|
||||
<TableHead className="w-[60px] text-center">FIFO</TableHead>
|
||||
<TableHead className="min-w-[100px]">LOT번호</TableHead>
|
||||
<TableHead className="w-[100px]">입고일</TableHead>
|
||||
<TableHead className="w-[70px] text-center">경과일</TableHead>
|
||||
<TableHead className="min-w-[100px]">공급업체</TableHead>
|
||||
<TableHead className="min-w-[120px]">발주번호</TableHead>
|
||||
<TableHead className="w-[80px] text-center">수량</TableHead>
|
||||
<TableHead className="w-[60px] text-center">위치</TableHead>
|
||||
<TableHead className="w-[80px] text-center">상태</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{detail.lots.map((lot: LotDetail) => (
|
||||
<TableRow key={lot.id}>
|
||||
<TableCell className="text-center">
|
||||
<div className="inline-flex items-center justify-center w-6 h-6 rounded-full bg-primary text-primary-foreground text-xs font-medium">
|
||||
{lot.fifoOrder}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="font-medium">{lot.lotNo}</TableCell>
|
||||
<TableCell>{lot.receiptDate}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<span className={lot.daysElapsed > 30 ? 'text-orange-600 font-medium' : ''}>
|
||||
{lot.daysElapsed}일
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>{lot.supplier}</TableCell>
|
||||
<TableCell>{lot.poNumber}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{lot.qty} {lot.unit}
|
||||
</TableCell>
|
||||
<TableCell className="text-center">{lot.location}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{LOT_STATUS_LABELS[lot.status]}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{/* 합계 행 */}
|
||||
<TableRow className="bg-muted/30 font-medium">
|
||||
<TableCell colSpan={6} className="text-right">
|
||||
합계:
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{totalQty} {detail.unit}
|
||||
</TableCell>
|
||||
<TableCell colSpan={2} />
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* FIFO 권장 메시지 */}
|
||||
{oldestLot && oldestLot.daysElapsed > 30 && (
|
||||
<div className="flex items-start gap-2 p-4 bg-orange-50 border border-orange-200 rounded-lg">
|
||||
<AlertCircle className="w-5 h-5 text-orange-600 flex-shrink-0 mt-0.5" />
|
||||
<div className="text-sm text-orange-800">
|
||||
<span className="font-medium">FIFO 권장:</span> LOT {oldestLot.lotNo}가{' '}
|
||||
{oldestLot.daysElapsed}일 경과되었습니다. 우선 사용을 권장합니다.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
342
src/components/material/StockStatus/StockStatusList.tsx
Normal file
342
src/components/material/StockStatus/StockStatusList.tsx
Normal file
@@ -0,0 +1,342 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 재고현황 목록 페이지
|
||||
* IntegratedListTemplateV2 패턴 적용
|
||||
*/
|
||||
|
||||
import { useState, useMemo, useCallback } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import {
|
||||
Package,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
AlertCircle,
|
||||
Download,
|
||||
Eye,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { TableCell, TableRow } from '@/components/ui/table';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import {
|
||||
IntegratedListTemplateV2,
|
||||
TabOption,
|
||||
TableColumn,
|
||||
StatCard,
|
||||
} from '@/components/templates/IntegratedListTemplateV2';
|
||||
import { ListMobileCard, InfoField } from '@/components/organisms/ListMobileCard';
|
||||
import { mockStockItems, mockStats, mockFilterTabs } from './mockData';
|
||||
import { ITEM_TYPE_LABELS, ITEM_TYPE_STYLES, STOCK_STATUS_LABELS } from './types';
|
||||
import type { StockItem, ItemType } from './types';
|
||||
|
||||
// 페이지당 항목 수
|
||||
const ITEMS_PER_PAGE = 20;
|
||||
|
||||
export function StockStatusList() {
|
||||
const router = useRouter();
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set());
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [activeFilter, setActiveFilter] = useState<'all' | ItemType>('all');
|
||||
|
||||
// 통계 카드
|
||||
const stats: StatCard[] = [
|
||||
{
|
||||
label: '전체 품목',
|
||||
value: `${mockStats.totalItems}종`,
|
||||
icon: Package,
|
||||
iconColor: 'text-gray-600',
|
||||
},
|
||||
{
|
||||
label: '정상 재고',
|
||||
value: `${mockStats.normalCount}종`,
|
||||
icon: CheckCircle2,
|
||||
iconColor: 'text-green-600',
|
||||
},
|
||||
{
|
||||
label: '재고 부족',
|
||||
value: `${mockStats.lowCount}종`,
|
||||
icon: Clock,
|
||||
iconColor: 'text-orange-600',
|
||||
},
|
||||
{
|
||||
label: '재고 없음',
|
||||
value: `${mockStats.outCount}종`,
|
||||
icon: AlertCircle,
|
||||
iconColor: 'text-red-600',
|
||||
},
|
||||
];
|
||||
|
||||
// 테이블 컬럼
|
||||
const tableColumns: TableColumn[] = [
|
||||
{ key: 'no', label: '번호', className: 'w-[60px] text-center' },
|
||||
{ key: 'itemCode', label: '품목코드', className: 'min-w-[120px]' },
|
||||
{ key: 'itemName', label: '품목명', className: 'min-w-[200px]' },
|
||||
{ key: 'itemType', label: '품목유형', className: 'w-[100px]' },
|
||||
{ key: 'unit', label: '단위', className: 'w-[60px] text-center' },
|
||||
{ key: 'stockQty', label: '재고량', className: 'w-[80px] text-center' },
|
||||
{ key: 'safetyStock', label: '안전재고', className: 'w-[80px] text-center' },
|
||||
{ key: 'lot', label: 'LOT', className: 'w-[100px] text-center' },
|
||||
{ key: 'status', label: '상태', className: 'w-[60px] text-center' },
|
||||
{ key: 'location', label: '위치', className: 'w-[60px] text-center' },
|
||||
{ key: 'actions', label: '작업', className: 'w-[60px] text-center' },
|
||||
];
|
||||
|
||||
// 필터링된 데이터
|
||||
const filteredResults = useMemo(() => {
|
||||
let result = [...mockStockItems];
|
||||
|
||||
// 품목유형 필터
|
||||
if (activeFilter !== 'all') {
|
||||
result = result.filter((item) => item.itemType === activeFilter);
|
||||
}
|
||||
|
||||
// 검색 필터
|
||||
if (searchTerm) {
|
||||
const term = searchTerm.toLowerCase();
|
||||
result = result.filter(
|
||||
(item) =>
|
||||
item.itemCode.toLowerCase().includes(term) ||
|
||||
item.itemName.toLowerCase().includes(term)
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [searchTerm, activeFilter]);
|
||||
|
||||
// 페이지네이션 데이터
|
||||
const paginatedData = useMemo(() => {
|
||||
const startIndex = (currentPage - 1) * ITEMS_PER_PAGE;
|
||||
return filteredResults.slice(startIndex, startIndex + ITEMS_PER_PAGE);
|
||||
}, [filteredResults, currentPage]);
|
||||
|
||||
// 페이지네이션 설정
|
||||
const pagination = {
|
||||
currentPage,
|
||||
totalPages: Math.ceil(filteredResults.length / ITEMS_PER_PAGE),
|
||||
totalItems: filteredResults.length,
|
||||
itemsPerPage: ITEMS_PER_PAGE,
|
||||
onPageChange: setCurrentPage,
|
||||
};
|
||||
|
||||
// 선택 핸들러
|
||||
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((item) => item.id)));
|
||||
}
|
||||
}, [paginatedData, selectedItems.size]);
|
||||
|
||||
// 검색 변경 시 페이지 리셋
|
||||
const handleSearchChange = (value: string) => {
|
||||
setSearchTerm(value);
|
||||
setCurrentPage(1);
|
||||
};
|
||||
|
||||
// 탭 변경 시 페이지 리셋
|
||||
const handleTabChange = (value: string) => {
|
||||
setActiveFilter(value as 'all' | ItemType);
|
||||
setCurrentPage(1);
|
||||
setSelectedItems(new Set());
|
||||
};
|
||||
|
||||
// 엑셀 다운로드
|
||||
const handleExcelDownload = useCallback(() => {
|
||||
console.log('엑셀 다운로드:', filteredResults);
|
||||
// TODO: 엑셀 다운로드 기능 구현
|
||||
}, [filteredResults]);
|
||||
|
||||
// 상세 보기
|
||||
const handleView = useCallback((id: string) => {
|
||||
router.push(`/ko/material/stock-status/${id}`);
|
||||
}, [router]);
|
||||
|
||||
// 재고부족 수 계산
|
||||
const lowStockCount = useMemo(() => {
|
||||
return filteredResults.filter((item) => item.status === 'low').length;
|
||||
}, [filteredResults]);
|
||||
|
||||
// 테이블 행 렌더링
|
||||
const renderTableRow = (item: StockItem, index: number, globalIndex: number) => {
|
||||
const isSelected = selectedItems.has(item.id);
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
key={item.id}
|
||||
className={`cursor-pointer hover:bg-muted/50 ${isSelected ? 'bg-blue-50' : ''}`}
|
||||
onClick={() => handleView(item.id)}
|
||||
>
|
||||
<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 className="font-medium">{item.itemCode}</TableCell>
|
||||
<TableCell className="max-w-[200px] truncate">{item.itemName}</TableCell>
|
||||
<TableCell>
|
||||
<Badge className={`text-xs ${ITEM_TYPE_STYLES[item.itemType]}`}>
|
||||
{ITEM_TYPE_LABELS[item.itemType]}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">{item.unit}</TableCell>
|
||||
<TableCell className="text-center">{item.stockQty}</TableCell>
|
||||
<TableCell className="text-center">{item.safetyStock}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<div className="flex flex-col items-center">
|
||||
<span>{item.lotCount}개</span>
|
||||
{item.lotDaysElapsed > 0 && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{item.lotDaysElapsed}일 경과
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<span className={item.status === 'low' ? 'text-orange-600 font-medium' : ''}>
|
||||
{STOCK_STATUS_LABELS[item.status]}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">{item.location}</TableCell>
|
||||
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
|
||||
{isSelected && (
|
||||
<Eye
|
||||
className="w-5 h-5 text-gray-500 hover:text-gray-700 cursor-pointer mx-auto"
|
||||
onClick={() => handleView(item.id)}
|
||||
/>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
};
|
||||
|
||||
// 모바일 카드 렌더링
|
||||
const renderMobileCard = (
|
||||
item: StockItem,
|
||||
index: number,
|
||||
globalIndex: number,
|
||||
isSelected: boolean,
|
||||
onToggle: () => void
|
||||
) => {
|
||||
return (
|
||||
<ListMobileCard
|
||||
id={item.id}
|
||||
isSelected={isSelected}
|
||||
onToggleSelection={onToggle}
|
||||
onClick={() => handleView(item.id)}
|
||||
headerBadges={
|
||||
<>
|
||||
<Badge variant="outline" className="text-xs">#{globalIndex}</Badge>
|
||||
<Badge variant="outline" className="text-xs">{item.itemCode}</Badge>
|
||||
</>
|
||||
}
|
||||
title={item.itemName}
|
||||
statusBadge={
|
||||
<Badge className={`text-xs ${ITEM_TYPE_STYLES[item.itemType]}`}>
|
||||
{ITEM_TYPE_LABELS[item.itemType]}
|
||||
</Badge>
|
||||
}
|
||||
infoGrid={
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-3">
|
||||
<InfoField label="단위" value={item.unit} />
|
||||
<InfoField label="위치" value={item.location} />
|
||||
<InfoField label="재고량" value={`${item.stockQty}`} />
|
||||
<InfoField label="안전재고" value={`${item.safetyStock}`} />
|
||||
<InfoField
|
||||
label="LOT"
|
||||
value={`${item.lotCount}개${item.lotDaysElapsed > 0 ? ` (${item.lotDaysElapsed}일 경과)` : ''}`}
|
||||
/>
|
||||
<InfoField
|
||||
label="상태"
|
||||
value={STOCK_STATUS_LABELS[item.status]}
|
||||
className={item.status === 'low' ? 'text-orange-600' : ''}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
actions={
|
||||
isSelected && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="flex-1"
|
||||
onClick={(e) => { e.stopPropagation(); handleView(item.id); }}
|
||||
>
|
||||
<Eye className="w-4 h-4 mr-1" />
|
||||
상세
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
// 탭 옵션 (TabChip 사용)
|
||||
const tabs: TabOption[] = mockFilterTabs.map((tab) => ({
|
||||
value: tab.key,
|
||||
label: tab.label,
|
||||
count: tab.count,
|
||||
}));
|
||||
|
||||
// 헤더 액션
|
||||
const headerActions = (
|
||||
<Button variant="outline" onClick={handleExcelDownload}>
|
||||
<Download className="w-4 h-4 mr-1.5" />
|
||||
엑셀 다운로드
|
||||
</Button>
|
||||
);
|
||||
|
||||
// 하단 요약 (테이블 푸터)
|
||||
const tableFooter = (
|
||||
<TableRow className="bg-gray-50 hover:bg-gray-50">
|
||||
<TableCell colSpan={tableColumns.length + 1} className="py-3">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
총 {filteredResults.length}종 / 재고부족 {lowStockCount}종
|
||||
</span>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
|
||||
return (
|
||||
<IntegratedListTemplateV2<StockItem>
|
||||
title="재고 목록"
|
||||
icon={Package}
|
||||
headerActions={headerActions}
|
||||
stats={stats}
|
||||
searchValue={searchTerm}
|
||||
onSearchChange={handleSearchChange}
|
||||
searchPlaceholder="품목코드, 품목명 검색..."
|
||||
tabs={tabs}
|
||||
activeTab={activeFilter}
|
||||
onTabChange={handleTabChange}
|
||||
tableColumns={tableColumns}
|
||||
data={paginatedData}
|
||||
totalCount={filteredResults.length}
|
||||
allData={filteredResults}
|
||||
selectedItems={selectedItems}
|
||||
onToggleSelection={handleToggleSelection}
|
||||
onToggleSelectAll={handleToggleSelectAll}
|
||||
getItemId={(item) => item.id}
|
||||
renderTableRow={renderTableRow}
|
||||
renderMobileCard={renderMobileCard}
|
||||
pagination={pagination}
|
||||
tableFooter={tableFooter}
|
||||
/>
|
||||
);
|
||||
}
|
||||
3
src/components/material/StockStatus/index.ts
Normal file
3
src/components/material/StockStatus/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { StockStatusList } from './StockStatusList';
|
||||
export { StockStatusDetail } from './StockStatusDetail';
|
||||
export * from './types';
|
||||
534
src/components/material/StockStatus/mockData.ts
Normal file
534
src/components/material/StockStatus/mockData.ts
Normal file
@@ -0,0 +1,534 @@
|
||||
/**
|
||||
* 재고현황 Mock 데이터
|
||||
*/
|
||||
|
||||
import type { StockItem, StockDetail, StockStats, FilterTab } from './types';
|
||||
|
||||
// 재고 상태 결정 함수
|
||||
function getStockStatus(stockQty: number, safetyStock: number): 'normal' | 'low' | 'out' {
|
||||
if (stockQty === 0) return 'out';
|
||||
if (stockQty < safetyStock) return 'low';
|
||||
return 'normal';
|
||||
}
|
||||
|
||||
// 시드 기반 의사 난수 생성 (일관된 결과 보장)
|
||||
function seededRandom(seed: number): number {
|
||||
const x = Math.sin(seed) * 10000;
|
||||
return x - Math.floor(x);
|
||||
}
|
||||
|
||||
function seededInt(seed: number, min: number, max: number): number {
|
||||
return Math.floor(seededRandom(seed) * (max - min + 1)) + min;
|
||||
}
|
||||
|
||||
// 위치 코드 생성 (시드 기반)
|
||||
function generateLocation(type: string, seed: number): string {
|
||||
const prefixes: Record<string, string[]> = {
|
||||
raw_material: ['A'],
|
||||
bent_part: ['C', 'D'],
|
||||
purchased_part: ['I', 'J'],
|
||||
sub_material: ['A', 'B'],
|
||||
consumable: ['B'],
|
||||
};
|
||||
const prefixList = prefixes[type] || ['A'];
|
||||
const prefixIndex = seededInt(seed, 0, prefixList.length - 1);
|
||||
const prefix = prefixList[prefixIndex];
|
||||
return `${prefix}-${String(seededInt(seed + 1, 1, 10)).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
// 원자재 데이터 (4개)
|
||||
const rawMaterialItems: StockItem[] = [
|
||||
{
|
||||
id: 'rm-1',
|
||||
itemCode: 'SCR-FABRIC-WHT-03T',
|
||||
itemName: '스크린원단-백색-0.3T',
|
||||
itemType: 'raw_material',
|
||||
unit: 'm²',
|
||||
stockQty: 500,
|
||||
safetyStock: 100,
|
||||
lotCount: 3,
|
||||
lotDaysElapsed: 21,
|
||||
status: 'normal',
|
||||
location: 'A-01',
|
||||
},
|
||||
{
|
||||
id: 'rm-2',
|
||||
itemCode: 'SCR-FABRIC-GRY-03T',
|
||||
itemName: '스크린원단-회색-0.3T',
|
||||
itemType: 'raw_material',
|
||||
unit: 'm²',
|
||||
stockQty: 350,
|
||||
safetyStock: 80,
|
||||
lotCount: 2,
|
||||
lotDaysElapsed: 15,
|
||||
status: 'normal',
|
||||
location: 'A-02',
|
||||
},
|
||||
{
|
||||
id: 'rm-3',
|
||||
itemCode: 'SCR-FABRIC-BLK-03T',
|
||||
itemName: '스크린원단-흑색-0.3T',
|
||||
itemType: 'raw_material',
|
||||
unit: 'm²',
|
||||
stockQty: 280,
|
||||
safetyStock: 70,
|
||||
lotCount: 2,
|
||||
lotDaysElapsed: 18,
|
||||
status: 'normal',
|
||||
location: 'A-03',
|
||||
},
|
||||
{
|
||||
id: 'rm-4',
|
||||
itemCode: 'SCR-FABRIC-BEI-03T',
|
||||
itemName: '스크린원단-베이지-0.3T',
|
||||
itemType: 'raw_material',
|
||||
unit: 'm²',
|
||||
stockQty: 420,
|
||||
safetyStock: 90,
|
||||
lotCount: 4,
|
||||
lotDaysElapsed: 12,
|
||||
status: 'normal',
|
||||
location: 'A-04',
|
||||
},
|
||||
];
|
||||
|
||||
// 절곡부품 데이터 (41개)
|
||||
const bentPartItems: StockItem[] = Array.from({ length: 41 }, (_, i) => {
|
||||
const types = ['브라켓', '지지대', '연결판', '고정판', '받침대', '가이드', '프레임'];
|
||||
const variants = ['A', 'B', 'C', 'D', 'E', 'S', 'M', 'L', 'XL'];
|
||||
const type = types[i % types.length];
|
||||
const variant = variants[i % variants.length];
|
||||
const seed = i * 100;
|
||||
const stockQty = seededInt(seed, 50, 500);
|
||||
const safetyStock = seededInt(seed + 1, 20, 100);
|
||||
|
||||
return {
|
||||
id: `bp-${i + 1}`,
|
||||
itemCode: `BENT-${type.toUpperCase().slice(0, 3)}-${variant}-${String(i + 1).padStart(2, '0')}`,
|
||||
itemName: `${type}-${variant}형-${i + 1}`,
|
||||
itemType: 'bent_part' as const,
|
||||
unit: 'EA',
|
||||
stockQty,
|
||||
safetyStock,
|
||||
lotCount: seededInt(seed + 2, 1, 5),
|
||||
lotDaysElapsed: seededInt(seed + 3, 0, 45),
|
||||
status: getStockStatus(stockQty, safetyStock),
|
||||
location: generateLocation('bent_part', seed + 4),
|
||||
};
|
||||
});
|
||||
|
||||
// 구매부품 데이터 (80개)
|
||||
const purchasedPartItems: StockItem[] = [
|
||||
// 각파이프류 (20개)
|
||||
...Array.from({ length: 20 }, (_, i) => {
|
||||
const sizes = ['30×30', '40×40', '50×50', '60×60', '75×75'];
|
||||
const lengths = ['3000', '4000', '5000', '6000'];
|
||||
const size = sizes[i % sizes.length];
|
||||
const length = lengths[i % lengths.length];
|
||||
const seed = 1000 + i * 10;
|
||||
const stockQty = seededInt(seed, 80, 300);
|
||||
const safetyStock = seededInt(seed + 1, 30, 60);
|
||||
|
||||
return {
|
||||
id: `pp-sqp-${i + 1}`,
|
||||
itemCode: `SQP-${size.replace('×', '')}-${length.slice(0, 2)}`,
|
||||
itemName: `각파이프 ${size} L:${length}`,
|
||||
itemType: 'purchased_part' as const,
|
||||
unit: 'EA',
|
||||
stockQty,
|
||||
safetyStock,
|
||||
lotCount: seededInt(seed + 2, 2, 5),
|
||||
lotDaysElapsed: seededInt(seed + 3, 0, 40),
|
||||
status: getStockStatus(stockQty, safetyStock),
|
||||
location: 'I-05',
|
||||
};
|
||||
}),
|
||||
// 앵글류 (15개)
|
||||
...Array.from({ length: 15 }, (_, i) => {
|
||||
const sizes = ['50×50', '65×65', '75×75', '90×90', '100×100'];
|
||||
const lengths = ['3000', '4000', '5000'];
|
||||
const size = sizes[i % sizes.length];
|
||||
const length = lengths[i % lengths.length];
|
||||
const seed = 2000 + i * 10;
|
||||
const stockQty = seededInt(seed, 60, 200);
|
||||
const safetyStock = seededInt(seed + 1, 20, 50);
|
||||
|
||||
return {
|
||||
id: `pp-ang-${i + 1}`,
|
||||
itemCode: `ANG-${size.replace('×', '')}-${length.slice(0, 2)}`,
|
||||
itemName: `앵글 ${size} L:${length}`,
|
||||
itemType: 'purchased_part' as const,
|
||||
unit: 'EA',
|
||||
stockQty,
|
||||
safetyStock,
|
||||
lotCount: seededInt(seed + 2, 2, 4),
|
||||
lotDaysElapsed: seededInt(seed + 3, 0, 35),
|
||||
status: getStockStatus(stockQty, safetyStock),
|
||||
location: 'I-04',
|
||||
};
|
||||
}),
|
||||
// 전동개폐기류 (10개)
|
||||
...Array.from({ length: 10 }, (_, i) => {
|
||||
const weights = ['300KG', '500KG', '700KG', '1000KG'];
|
||||
const voltages = ['110V', '220V', '380V'];
|
||||
const types = ['유선', '무선'];
|
||||
const weight = weights[i % weights.length];
|
||||
const voltage = voltages[i % voltages.length];
|
||||
const type = types[i % types.length];
|
||||
const seed = 3000 + i * 10;
|
||||
const stockQty = seededInt(seed, 10, 50);
|
||||
const safetyStock = seededInt(seed + 1, 5, 15);
|
||||
|
||||
return {
|
||||
id: `pp-motor-${i + 1}`,
|
||||
itemCode: `MOTOR-${voltage}${weight}${type === '무선' ? '-W' : ''}`,
|
||||
itemName: `전동개폐기-${voltage}${weight}${type}`,
|
||||
itemType: 'purchased_part' as const,
|
||||
unit: 'EA',
|
||||
stockQty,
|
||||
safetyStock,
|
||||
lotCount: seededInt(seed + 2, 1, 3),
|
||||
lotDaysElapsed: seededInt(seed + 3, 0, 30),
|
||||
status: getStockStatus(stockQty, safetyStock),
|
||||
location: 'I-01',
|
||||
};
|
||||
}),
|
||||
// 볼트/너트류 (15개)
|
||||
...Array.from({ length: 15 }, (_, i) => {
|
||||
const sizes = ['M6', 'M8', 'M10', 'M12', 'M16'];
|
||||
const lengths = ['20', '30', '40', '50', '60'];
|
||||
const size = sizes[i % sizes.length];
|
||||
const length = lengths[i % lengths.length];
|
||||
const seed = 4000 + i * 10;
|
||||
const stockQty = seededInt(seed, 500, 2000);
|
||||
const safetyStock = seededInt(seed + 1, 100, 500);
|
||||
|
||||
return {
|
||||
id: `pp-bolt-${i + 1}`,
|
||||
itemCode: `BOLT-${size}-${length}`,
|
||||
itemName: `볼트 ${size}×${length}mm`,
|
||||
itemType: 'purchased_part' as const,
|
||||
unit: 'EA',
|
||||
stockQty,
|
||||
safetyStock,
|
||||
lotCount: seededInt(seed + 2, 3, 6),
|
||||
lotDaysElapsed: seededInt(seed + 3, 0, 25),
|
||||
status: getStockStatus(stockQty, safetyStock),
|
||||
location: 'J-01',
|
||||
};
|
||||
}),
|
||||
// 베어링류 (10개)
|
||||
...Array.from({ length: 10 }, (_, i) => {
|
||||
const types = ['6200', '6201', '6202', '6203', '6204', '6205', '6206', '6207', '6208', '6209'];
|
||||
const type = types[i % types.length];
|
||||
const seed = 5000 + i * 10;
|
||||
const stockQty = seededInt(seed, 30, 150);
|
||||
const safetyStock = seededInt(seed + 1, 10, 40);
|
||||
|
||||
return {
|
||||
id: `pp-bearing-${i + 1}`,
|
||||
itemCode: `BEARING-${type}`,
|
||||
itemName: `베어링 ${type}`,
|
||||
itemType: 'purchased_part' as const,
|
||||
unit: 'EA',
|
||||
stockQty,
|
||||
safetyStock,
|
||||
lotCount: seededInt(seed + 2, 2, 4),
|
||||
lotDaysElapsed: seededInt(seed + 3, 0, 20),
|
||||
status: getStockStatus(stockQty, safetyStock),
|
||||
location: 'J-02',
|
||||
};
|
||||
}),
|
||||
// 스프링류 (10개)
|
||||
...Array.from({ length: 10 }, (_, i) => {
|
||||
const types = ['인장', '압축', '토션'];
|
||||
const sizes = ['S', 'M', 'L', 'XL'];
|
||||
const type = types[i % types.length];
|
||||
const size = sizes[i % sizes.length];
|
||||
const seed = 6000 + i * 10;
|
||||
const stockQty = seededInt(seed, 100, 400);
|
||||
const safetyStock = seededInt(seed + 1, 30, 80);
|
||||
|
||||
return {
|
||||
id: `pp-spring-${i + 1}`,
|
||||
itemCode: `SPRING-${type.toUpperCase().slice(0, 2)}-${size}`,
|
||||
itemName: `스프링-${type}-${size}`,
|
||||
itemType: 'purchased_part' as const,
|
||||
unit: 'EA',
|
||||
stockQty,
|
||||
safetyStock,
|
||||
lotCount: seededInt(seed + 2, 2, 5),
|
||||
lotDaysElapsed: seededInt(seed + 3, 0, 30),
|
||||
status: getStockStatus(stockQty, safetyStock),
|
||||
location: 'J-03',
|
||||
};
|
||||
}),
|
||||
];
|
||||
|
||||
// 부자재 데이터 (7개)
|
||||
const subMaterialItems: StockItem[] = [
|
||||
{
|
||||
id: 'sm-1',
|
||||
itemCode: 'SEW-WHT',
|
||||
itemName: '미싱실-백색',
|
||||
itemType: 'sub_material',
|
||||
unit: 'M',
|
||||
stockQty: 5000,
|
||||
safetyStock: 1000,
|
||||
lotCount: 3,
|
||||
lotDaysElapsed: 28,
|
||||
status: 'normal',
|
||||
location: 'A-04',
|
||||
},
|
||||
{
|
||||
id: 'sm-2',
|
||||
itemCode: 'ALU-BAR',
|
||||
itemName: '하단바-알루미늄',
|
||||
itemType: 'sub_material',
|
||||
unit: 'EA',
|
||||
stockQty: 120,
|
||||
safetyStock: 30,
|
||||
lotCount: 1,
|
||||
lotDaysElapsed: 5,
|
||||
status: 'normal',
|
||||
location: 'A-03',
|
||||
},
|
||||
{
|
||||
id: 'sm-3',
|
||||
itemCode: 'END-CAP-STD',
|
||||
itemName: '앤드락-표준',
|
||||
itemType: 'sub_material',
|
||||
unit: 'EA',
|
||||
stockQty: 800,
|
||||
safetyStock: 200,
|
||||
lotCount: 2,
|
||||
lotDaysElapsed: 12,
|
||||
status: 'normal',
|
||||
location: 'A-02',
|
||||
},
|
||||
{
|
||||
id: 'sm-4',
|
||||
itemCode: 'SILICON-TRANS',
|
||||
itemName: '실리콘-투명',
|
||||
itemType: 'sub_material',
|
||||
unit: 'EA',
|
||||
stockQty: 200,
|
||||
safetyStock: 50,
|
||||
lotCount: 5,
|
||||
lotDaysElapsed: 37,
|
||||
status: 'normal',
|
||||
location: 'B-03',
|
||||
},
|
||||
{
|
||||
id: 'sm-5',
|
||||
itemCode: 'TAPE-DBL-25',
|
||||
itemName: '양면테이프-25mm',
|
||||
itemType: 'sub_material',
|
||||
unit: 'EA',
|
||||
stockQty: 150,
|
||||
safetyStock: 40,
|
||||
lotCount: 2,
|
||||
lotDaysElapsed: 10,
|
||||
status: 'normal',
|
||||
location: 'B-02',
|
||||
},
|
||||
{
|
||||
id: 'sm-6',
|
||||
itemCode: 'RIVET-STL-4',
|
||||
itemName: '리벳-스틸-4mm',
|
||||
itemType: 'sub_material',
|
||||
unit: 'EA',
|
||||
stockQty: 3000,
|
||||
safetyStock: 500,
|
||||
lotCount: 4,
|
||||
lotDaysElapsed: 8,
|
||||
status: 'normal',
|
||||
location: 'B-01',
|
||||
},
|
||||
{
|
||||
id: 'sm-7',
|
||||
itemCode: 'WASHER-M8',
|
||||
itemName: '와셔-M8',
|
||||
itemType: 'sub_material',
|
||||
unit: 'EA',
|
||||
stockQty: 2500,
|
||||
safetyStock: 400,
|
||||
lotCount: 3,
|
||||
lotDaysElapsed: 15,
|
||||
status: 'normal',
|
||||
location: 'B-04',
|
||||
},
|
||||
];
|
||||
|
||||
// 소모품 데이터 (2개)
|
||||
const consumableItems: StockItem[] = [
|
||||
{
|
||||
id: 'cs-1',
|
||||
itemCode: 'PKG-BOX-L',
|
||||
itemName: '포장박스-대형',
|
||||
itemType: 'consumable',
|
||||
unit: 'EA',
|
||||
stockQty: 200,
|
||||
safetyStock: 50,
|
||||
lotCount: 2,
|
||||
lotDaysElapsed: 8,
|
||||
status: 'normal',
|
||||
location: 'B-01',
|
||||
},
|
||||
{
|
||||
id: 'cs-2',
|
||||
itemCode: 'PKG-BOX-M',
|
||||
itemName: '포장박스-중형',
|
||||
itemType: 'consumable',
|
||||
unit: 'EA',
|
||||
stockQty: 350,
|
||||
safetyStock: 80,
|
||||
lotCount: 3,
|
||||
lotDaysElapsed: 5,
|
||||
status: 'normal',
|
||||
location: 'B-02',
|
||||
},
|
||||
];
|
||||
|
||||
// 재고 목록 Mock 데이터 (134개)
|
||||
export const mockStockItems: StockItem[] = [
|
||||
...rawMaterialItems, // 4개
|
||||
...bentPartItems, // 41개
|
||||
...purchasedPartItems, // 80개
|
||||
...subMaterialItems, // 7개
|
||||
...consumableItems, // 2개
|
||||
];
|
||||
|
||||
// ID로 아이템 찾기 헬퍼
|
||||
export function findStockItemById(id: string): StockItem | undefined {
|
||||
return mockStockItems.find(item => item.id === id);
|
||||
}
|
||||
|
||||
// 재고 상세 Mock 데이터 생성 함수
|
||||
export function generateStockDetail(item: StockItem): StockDetail {
|
||||
const suppliers = ['포스코', '현대제철', '동국제강', '세아제강', '한국철강', '삼성물산'];
|
||||
const locations = ['A-01', 'A-02', 'B-01', 'B-02', 'C-01'];
|
||||
|
||||
// 시드 기반으로 일관된 LOT 데이터 생성
|
||||
const itemSeed = parseInt(item.id.replace(/\D/g, '') || '0', 10);
|
||||
|
||||
// LOT 데이터 생성
|
||||
const lots = Array.from({ length: item.lotCount }, (_, i) => {
|
||||
const lotSeed = itemSeed * 1000 + i * 100;
|
||||
const daysAgo = seededInt(lotSeed, 5, 60);
|
||||
const date = new Date('2025-12-23'); // 고정 날짜 사용
|
||||
date.setDate(date.getDate() - daysAgo);
|
||||
const dateStr = date.toISOString().split('T')[0];
|
||||
const lotDate = dateStr.replace(/-/g, '').slice(2);
|
||||
|
||||
return {
|
||||
id: `lot-${item.id}-${i + 1}`,
|
||||
fifoOrder: i + 1,
|
||||
lotNo: `${lotDate}-${String(i + 1).padStart(2, '0')}`,
|
||||
receiptDate: dateStr,
|
||||
daysElapsed: daysAgo,
|
||||
supplier: suppliers[seededInt(lotSeed + 1, 0, suppliers.length - 1)],
|
||||
poNumber: `PO-${lotDate}-${String(seededInt(lotSeed + 2, 1, 99)).padStart(2, '0')}`,
|
||||
qty: Math.floor(item.stockQty / item.lotCount) + (i === 0 ? item.stockQty % item.lotCount : 0),
|
||||
unit: item.unit,
|
||||
location: locations[seededInt(lotSeed + 3, 0, locations.length - 1)],
|
||||
status: 'available' as const,
|
||||
};
|
||||
});
|
||||
|
||||
// 카테고리 추출
|
||||
const categoryMap: Record<string, string> = {
|
||||
'SQP': '각파이프',
|
||||
'ANG': '앵글',
|
||||
'MOTOR': '전동개폐기',
|
||||
'BOLT': '볼트',
|
||||
'BEARING': '베어링',
|
||||
'SPRING': '스프링',
|
||||
'BENT': '절곡부품',
|
||||
'SCR': '스크린원단',
|
||||
'SEW': '미싱실',
|
||||
'ALU': '알루미늄바',
|
||||
'END': '앤드락',
|
||||
'SILICON': '실리콘',
|
||||
'TAPE': '테이프',
|
||||
'RIVET': '리벳',
|
||||
'WASHER': '와셔',
|
||||
'PKG': '포장재',
|
||||
};
|
||||
|
||||
const prefix = item.itemCode.split('-')[0];
|
||||
const category = categoryMap[prefix] || '기타';
|
||||
|
||||
return {
|
||||
id: item.id,
|
||||
itemCode: item.itemCode,
|
||||
itemName: item.itemName,
|
||||
itemType: item.itemType,
|
||||
category,
|
||||
specification: '-',
|
||||
unit: item.unit,
|
||||
currentStock: item.stockQty,
|
||||
safetyStock: item.safetyStock,
|
||||
location: item.location,
|
||||
lotCount: item.lotCount,
|
||||
lastReceiptDate: lots[lots.length - 1]?.receiptDate || '2025-12-23',
|
||||
status: item.status,
|
||||
lots,
|
||||
};
|
||||
}
|
||||
|
||||
// 재고 상세 Mock 데이터 (동적 생성)
|
||||
export const mockStockDetails: Record<string, StockDetail> = {};
|
||||
|
||||
// 상세 데이터 가져오기 함수
|
||||
export function getStockDetail(id: string): StockDetail | undefined {
|
||||
// 캐시된 데이터가 있으면 반환
|
||||
if (mockStockDetails[id]) {
|
||||
return mockStockDetails[id];
|
||||
}
|
||||
|
||||
// 없으면 생성
|
||||
const item = findStockItemById(id);
|
||||
if (item) {
|
||||
mockStockDetails[id] = generateStockDetail(item);
|
||||
return mockStockDetails[id];
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// 통계 데이터 계산
|
||||
const calculateStats = (): StockStats => {
|
||||
const normalCount = mockStockItems.filter(item => item.status === 'normal').length;
|
||||
const lowCount = mockStockItems.filter(item => item.status === 'low').length;
|
||||
const outCount = mockStockItems.filter(item => item.status === 'out').length;
|
||||
|
||||
return {
|
||||
totalItems: mockStockItems.length,
|
||||
normalCount,
|
||||
lowCount,
|
||||
outCount,
|
||||
};
|
||||
};
|
||||
|
||||
export const mockStats: StockStats = calculateStats();
|
||||
|
||||
// 필터 탭 데이터 계산
|
||||
const calculateFilterTabs = (): FilterTab[] => {
|
||||
const rawMaterialCount = mockStockItems.filter(item => item.itemType === 'raw_material').length;
|
||||
const bentPartCount = mockStockItems.filter(item => item.itemType === 'bent_part').length;
|
||||
const purchasedPartCount = mockStockItems.filter(item => item.itemType === 'purchased_part').length;
|
||||
const subMaterialCount = mockStockItems.filter(item => item.itemType === 'sub_material').length;
|
||||
const consumableCount = mockStockItems.filter(item => item.itemType === 'consumable').length;
|
||||
|
||||
return [
|
||||
{ key: 'all', label: '전체', count: mockStockItems.length },
|
||||
{ key: 'raw_material', label: '원자재', count: rawMaterialCount },
|
||||
{ key: 'bent_part', label: '절곡부품', count: bentPartCount },
|
||||
{ key: 'purchased_part', label: '구매부품', count: purchasedPartCount },
|
||||
{ key: 'sub_material', label: '부자재', count: subMaterialCount },
|
||||
{ key: 'consumable', label: '소모품', count: consumableCount },
|
||||
];
|
||||
};
|
||||
|
||||
export const mockFilterTabs: FilterTab[] = calculateFilterTabs();
|
||||
111
src/components/material/StockStatus/types.ts
Normal file
111
src/components/material/StockStatus/types.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* 재고현황 타입 정의
|
||||
*/
|
||||
|
||||
// 품목유형
|
||||
export type ItemType = 'raw_material' | 'bent_part' | 'purchased_part' | 'sub_material' | 'consumable';
|
||||
|
||||
// 품목유형 라벨
|
||||
export const ITEM_TYPE_LABELS: Record<ItemType, string> = {
|
||||
raw_material: '원자재',
|
||||
bent_part: '절곡부품',
|
||||
purchased_part: '구매부품',
|
||||
sub_material: '부자재',
|
||||
consumable: '소모품',
|
||||
};
|
||||
|
||||
// 품목유형 스타일 (뱃지용)
|
||||
export const ITEM_TYPE_STYLES: Record<ItemType, string> = {
|
||||
raw_material: 'bg-blue-100 text-blue-800',
|
||||
bent_part: 'bg-purple-100 text-purple-800',
|
||||
purchased_part: 'bg-gray-100 text-gray-800',
|
||||
sub_material: 'bg-green-100 text-green-800',
|
||||
consumable: 'bg-orange-100 text-orange-800',
|
||||
};
|
||||
|
||||
// 재고 상태
|
||||
export type StockStatusType = 'normal' | 'low' | 'out';
|
||||
|
||||
// 재고 상태 라벨
|
||||
export const STOCK_STATUS_LABELS: Record<StockStatusType, string> = {
|
||||
normal: '정상',
|
||||
low: '부족',
|
||||
out: '없음',
|
||||
};
|
||||
|
||||
// LOT 상태
|
||||
export type LotStatusType = 'available' | 'reserved' | 'used';
|
||||
|
||||
export const LOT_STATUS_LABELS: Record<LotStatusType, string> = {
|
||||
available: '사용가능',
|
||||
reserved: '예약됨',
|
||||
used: '사용완료',
|
||||
};
|
||||
|
||||
// 재고 목록 아이템
|
||||
export interface StockItem {
|
||||
id: string;
|
||||
itemCode: string; // 품목코드
|
||||
itemName: string; // 품목명
|
||||
itemType: ItemType; // 품목유형
|
||||
unit: string; // 단위 (EA, M, m² 등)
|
||||
stockQty: number; // 재고량
|
||||
safetyStock: number; // 안전재고
|
||||
lotCount: number; // LOT 개수
|
||||
lotDaysElapsed: number; // 경과일 (가장 오래된 LOT 기준)
|
||||
status: StockStatusType; // 상태
|
||||
location: string; // 위치
|
||||
}
|
||||
|
||||
// LOT별 상세 재고
|
||||
export interface LotDetail {
|
||||
id: string;
|
||||
fifoOrder: number; // FIFO 순서
|
||||
lotNo: string; // LOT번호
|
||||
receiptDate: string; // 입고일
|
||||
daysElapsed: number; // 경과일
|
||||
supplier: string; // 공급업체
|
||||
poNumber: string; // 발주번호
|
||||
qty: number; // 수량
|
||||
unit: string; // 단위
|
||||
location: string; // 위치
|
||||
status: LotStatusType; // 상태
|
||||
}
|
||||
|
||||
// 재고 상세 정보
|
||||
export interface StockDetail {
|
||||
// 기본 정보
|
||||
id: string;
|
||||
itemCode: string; // 품목코드
|
||||
itemName: string; // 품목명
|
||||
itemType: ItemType; // 품목유형
|
||||
category: string; // 카테고리
|
||||
specification: string; // 규격
|
||||
unit: string; // 단위
|
||||
|
||||
// 재고 현황
|
||||
currentStock: number; // 현재 재고량
|
||||
safetyStock: number; // 안전 재고
|
||||
location: string; // 재고 위치
|
||||
lotCount: number; // LOT 개수
|
||||
lastReceiptDate: string; // 최근 입고일
|
||||
status: StockStatusType; // 재고 상태
|
||||
|
||||
// LOT별 상세 재고
|
||||
lots: LotDetail[];
|
||||
}
|
||||
|
||||
// 통계 데이터
|
||||
export interface StockStats {
|
||||
totalItems: number; // 전체 품목 수
|
||||
normalCount: number; // 정상 재고 수
|
||||
lowCount: number; // 재고 부족 수
|
||||
outCount: number; // 재고 없음 수
|
||||
}
|
||||
|
||||
// 필터 탭
|
||||
export interface FilterTab {
|
||||
key: 'all' | ItemType;
|
||||
label: string;
|
||||
count: number;
|
||||
}
|
||||
312
src/components/orders/ItemAddDialog.tsx
Normal file
312
src/components/orders/ItemAddDialog.tsx
Normal file
@@ -0,0 +1,312 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 품목 추가 팝업
|
||||
*
|
||||
* 수주 등록 시 품목을 수동으로 추가하는 다이얼로그
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Package } from "lucide-react";
|
||||
|
||||
// 품목 타입
|
||||
export interface OrderItem {
|
||||
id: string;
|
||||
itemCode: string; // 품목코드
|
||||
itemName: string; // 품명
|
||||
type: string; // 층
|
||||
symbol: string; // 부호
|
||||
spec: string; // 규격
|
||||
width: number; // 가로 (mm)
|
||||
height: number; // 세로 (mm)
|
||||
quantity: number; // 수량
|
||||
unit: string; // 단위
|
||||
unitPrice: number; // 단가
|
||||
amount: number; // 금액
|
||||
guideRailType?: string; // 가이드레일 타입
|
||||
finish?: string; // 마감
|
||||
floor?: string; // 층
|
||||
isFromQuotation?: boolean; // 견적에서 가져온 품목 여부
|
||||
}
|
||||
|
||||
interface ItemAddDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onAdd: (item: OrderItem) => void;
|
||||
}
|
||||
|
||||
// 가이드레일 타입 옵션
|
||||
const GUIDE_RAIL_TYPES = [
|
||||
{ value: "back-120-70", label: "백면형 (120-70)" },
|
||||
{ value: "back-150-70", label: "백면형 (150-70)" },
|
||||
{ value: "side-120-70", label: "측면형 (120-70)" },
|
||||
{ value: "side-150-70", label: "측면형 (150-70)" },
|
||||
];
|
||||
|
||||
// 마감 옵션
|
||||
const FINISH_OPTIONS = [
|
||||
{ value: "sus", label: "SUS마감" },
|
||||
{ value: "powder", label: "분체도장" },
|
||||
{ value: "paint", label: "일반도장" },
|
||||
];
|
||||
|
||||
// 초기 폼 데이터
|
||||
const INITIAL_FORM = {
|
||||
floor: "",
|
||||
symbol: "",
|
||||
itemName: "",
|
||||
width: "",
|
||||
height: "",
|
||||
guideRailType: "",
|
||||
finish: "",
|
||||
unitPrice: "",
|
||||
};
|
||||
|
||||
export function ItemAddDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onAdd,
|
||||
}: ItemAddDialogProps) {
|
||||
const [form, setForm] = useState(INITIAL_FORM);
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
|
||||
// 폼 리셋
|
||||
const resetForm = () => {
|
||||
setForm(INITIAL_FORM);
|
||||
setErrors({});
|
||||
};
|
||||
|
||||
// 다이얼로그 닫기 시 리셋
|
||||
const handleOpenChange = (open: boolean) => {
|
||||
if (!open) {
|
||||
resetForm();
|
||||
}
|
||||
onOpenChange(open);
|
||||
};
|
||||
|
||||
// 유효성 검사
|
||||
const validate = (): boolean => {
|
||||
const newErrors: Record<string, string> = {};
|
||||
|
||||
if (!form.floor.trim()) {
|
||||
newErrors.floor = "층을 입력해주세요";
|
||||
}
|
||||
if (!form.symbol.trim()) {
|
||||
newErrors.symbol = "도면부호를 입력해주세요";
|
||||
}
|
||||
if (!form.width || Number(form.width) <= 0) {
|
||||
newErrors.width = "가로 치수를 입력해주세요";
|
||||
}
|
||||
if (!form.height || Number(form.height) <= 0) {
|
||||
newErrors.height = "세로 치수를 입력해주세요";
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
// 추가 핸들러
|
||||
const handleAdd = () => {
|
||||
if (!validate()) return;
|
||||
|
||||
const width = Number(form.width);
|
||||
const height = Number(form.height);
|
||||
const unitPrice = Number(form.unitPrice) || 0;
|
||||
|
||||
const newItem: OrderItem = {
|
||||
id: `item-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||
itemCode: `PRD-${Date.now().toString().slice(-4)}`,
|
||||
itemName: form.itemName || "국민방화스크린세터",
|
||||
type: "B1",
|
||||
symbol: form.symbol,
|
||||
spec: `${width}×${height}`,
|
||||
width,
|
||||
height,
|
||||
quantity: 1,
|
||||
unit: "EA",
|
||||
unitPrice,
|
||||
amount: unitPrice,
|
||||
guideRailType: form.guideRailType,
|
||||
finish: form.finish,
|
||||
floor: form.floor,
|
||||
};
|
||||
|
||||
onAdd(newItem);
|
||||
handleOpenChange(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Package className="h-5 w-5" />
|
||||
품목 추가
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
{/* 층 / 도면부호 */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="floor">
|
||||
층 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="floor"
|
||||
placeholder="예: 4층"
|
||||
value={form.floor}
|
||||
onChange={(e) => setForm({ ...form, floor: e.target.value })}
|
||||
/>
|
||||
{errors.floor && (
|
||||
<p className="text-xs text-red-500">{errors.floor}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="symbol">
|
||||
도면부호 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="symbol"
|
||||
placeholder="예: FSS1"
|
||||
value={form.symbol}
|
||||
onChange={(e) => setForm({ ...form, symbol: e.target.value })}
|
||||
/>
|
||||
{errors.symbol && (
|
||||
<p className="text-xs text-red-500">{errors.symbol}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 품목명 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="itemName">품목명</Label>
|
||||
<Input
|
||||
id="itemName"
|
||||
placeholder="예: 국민방화스크린세터"
|
||||
value={form.itemName}
|
||||
onChange={(e) => setForm({ ...form, itemName: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 오픈사이즈 (고객 제공 치수) */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-muted-foreground text-sm">
|
||||
오픈사이즈 (고객 제공 치수)
|
||||
</Label>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="width">
|
||||
가로 (mm) <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="width"
|
||||
type="number"
|
||||
placeholder="예: 7260"
|
||||
value={form.width}
|
||||
onChange={(e) => setForm({ ...form, width: e.target.value })}
|
||||
/>
|
||||
{errors.width && (
|
||||
<p className="text-xs text-red-500">{errors.width}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="height">
|
||||
세로 (mm) <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="height"
|
||||
type="number"
|
||||
placeholder="예: 2600"
|
||||
value={form.height}
|
||||
onChange={(e) => setForm({ ...form, height: e.target.value })}
|
||||
/>
|
||||
{errors.height && (
|
||||
<p className="text-xs text-red-500">{errors.height}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 가이드레일 타입 / 마감 */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>가이드레일 타입</Label>
|
||||
<Select
|
||||
value={form.guideRailType}
|
||||
onValueChange={(value) =>
|
||||
setForm({ ...form, guideRailType: value })
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{GUIDE_RAIL_TYPES.map((type) => (
|
||||
<SelectItem key={type.value} value={type.value}>
|
||||
{type.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>마감</Label>
|
||||
<Select
|
||||
value={form.finish}
|
||||
onValueChange={(value) => setForm({ ...form, finish: value })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{FINISH_OPTIONS.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 단가 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="unitPrice">단가 (원)</Label>
|
||||
<Input
|
||||
id="unitPrice"
|
||||
type="number"
|
||||
placeholder="예: 8000000"
|
||||
value={form.unitPrice}
|
||||
onChange={(e) => setForm({ ...form, unitPrice: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => handleOpenChange(false)}>
|
||||
취소
|
||||
</Button>
|
||||
<Button onClick={handleAdd}>추가</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
870
src/components/orders/OrderRegistration.tsx
Normal file
870
src/components/orders/OrderRegistration.tsx
Normal file
@@ -0,0 +1,870 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 수주 등록 컴포넌트
|
||||
*
|
||||
* - 견적 불러오기 섹션
|
||||
* - 기본 정보 섹션
|
||||
* - 수주/배송 정보 섹션
|
||||
* - 수신처 주소 섹션
|
||||
* - 비고 섹션
|
||||
* - 품목 내역 섹션
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import {
|
||||
FileText,
|
||||
Search,
|
||||
X,
|
||||
Plus,
|
||||
Trash2,
|
||||
Info,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
ResponsiveFormTemplate,
|
||||
FormSection,
|
||||
} from "@/components/templates/ResponsiveFormTemplate";
|
||||
import { QuotationSelectDialog, QuotationForSelect, QuotationItem } from "./QuotationSelectDialog";
|
||||
import { ItemAddDialog, OrderItem } from "./ItemAddDialog";
|
||||
import { formatAmount } from "@/utils/formatAmount";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// 수주 폼 데이터 타입
|
||||
export interface OrderFormData {
|
||||
// 견적 정보
|
||||
selectedQuotation?: QuotationForSelect;
|
||||
|
||||
// 기본 정보
|
||||
clientId: string;
|
||||
clientName: string;
|
||||
siteName: string;
|
||||
manager: string;
|
||||
contact: string;
|
||||
|
||||
// 수주/배송 정보
|
||||
expectedShipDate: string;
|
||||
expectedShipDateUndecided: boolean;
|
||||
deliveryRequestDate: string;
|
||||
deliveryRequestDateUndecided: boolean;
|
||||
deliveryMethod: string;
|
||||
shippingCost: string;
|
||||
receiver: string;
|
||||
receiverContact: string;
|
||||
|
||||
// 수신처 주소
|
||||
zipCode: string;
|
||||
address: string;
|
||||
addressDetail: string;
|
||||
|
||||
// 비고
|
||||
remarks: string;
|
||||
|
||||
// 품목 내역
|
||||
items: OrderItem[];
|
||||
|
||||
// 금액 정보
|
||||
subtotal: number;
|
||||
discountRate: number;
|
||||
totalAmount: number;
|
||||
}
|
||||
|
||||
// 초기 폼 데이터
|
||||
const INITIAL_FORM: OrderFormData = {
|
||||
clientId: "",
|
||||
clientName: "",
|
||||
siteName: "",
|
||||
manager: "",
|
||||
contact: "",
|
||||
expectedShipDate: "",
|
||||
expectedShipDateUndecided: false,
|
||||
deliveryRequestDate: "",
|
||||
deliveryRequestDateUndecided: false,
|
||||
deliveryMethod: "",
|
||||
shippingCost: "",
|
||||
receiver: "",
|
||||
receiverContact: "",
|
||||
zipCode: "",
|
||||
address: "",
|
||||
addressDetail: "",
|
||||
remarks: "",
|
||||
items: [],
|
||||
subtotal: 0,
|
||||
discountRate: 0,
|
||||
totalAmount: 0,
|
||||
};
|
||||
|
||||
// 배송방식 옵션
|
||||
const DELIVERY_METHODS = [
|
||||
{ value: "direct", label: "직접배차" },
|
||||
{ value: "pickup", label: "상차" },
|
||||
{ value: "courier", label: "택배" },
|
||||
];
|
||||
|
||||
// 운임비용 옵션
|
||||
const SHIPPING_COSTS = [
|
||||
{ value: "free", label: "무료" },
|
||||
{ value: "prepaid", label: "선불" },
|
||||
{ value: "collect", label: "착불" },
|
||||
{ value: "negotiable", label: "협의" },
|
||||
];
|
||||
|
||||
// 샘플 발주처 데이터
|
||||
const SAMPLE_CLIENTS = [
|
||||
{ id: "C001", name: "태영건설(주)" },
|
||||
{ id: "C002", name: "현대건설(주)" },
|
||||
{ id: "C003", name: "GS건설(주)" },
|
||||
{ id: "C004", name: "대우건설(주)" },
|
||||
{ id: "C005", name: "포스코건설" },
|
||||
];
|
||||
|
||||
interface OrderRegistrationProps {
|
||||
onBack: () => void;
|
||||
onSave: (formData: OrderFormData) => Promise<void>;
|
||||
initialData?: Partial<OrderFormData>;
|
||||
isEditMode?: boolean;
|
||||
}
|
||||
|
||||
// 필드별 에러 타입
|
||||
interface FieldErrors {
|
||||
clientName?: string;
|
||||
siteName?: string;
|
||||
deliveryRequestDate?: string;
|
||||
receiver?: string;
|
||||
receiverContact?: string;
|
||||
items?: string;
|
||||
}
|
||||
|
||||
// 필드명 한글 매핑
|
||||
const FIELD_NAME_MAP: Record<string, string> = {
|
||||
clientName: "발주처",
|
||||
siteName: "현장명",
|
||||
deliveryRequestDate: "납품요청일",
|
||||
receiver: "수신(반장/업체)",
|
||||
receiverContact: "수신처 연락처",
|
||||
items: "품목 내역",
|
||||
};
|
||||
|
||||
export function OrderRegistration({
|
||||
onBack,
|
||||
onSave,
|
||||
initialData,
|
||||
isEditMode = false,
|
||||
}: OrderRegistrationProps) {
|
||||
const [form, setForm] = useState<OrderFormData>({
|
||||
...INITIAL_FORM,
|
||||
...initialData,
|
||||
});
|
||||
const [isQuotationDialogOpen, setIsQuotationDialogOpen] = useState(false);
|
||||
const [isItemDialogOpen, setIsItemDialogOpen] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [fieldErrors, setFieldErrors] = useState<FieldErrors>({});
|
||||
|
||||
// 금액 계산
|
||||
useEffect(() => {
|
||||
const subtotal = form.items.reduce((sum, item) => sum + item.amount, 0);
|
||||
const discountAmount = subtotal * (form.discountRate / 100);
|
||||
const totalAmount = subtotal - discountAmount;
|
||||
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
subtotal,
|
||||
totalAmount,
|
||||
}));
|
||||
}, [form.items, form.discountRate]);
|
||||
|
||||
// 견적 선택 핸들러
|
||||
const handleQuotationSelect = (quotation: QuotationForSelect) => {
|
||||
// 견적 정보로 폼 자동 채우기 (견적에서 가져온 품목은 삭제 불가)
|
||||
const items: OrderItem[] = (quotation.items || []).map((qi: QuotationItem) => ({
|
||||
id: qi.id,
|
||||
itemCode: qi.itemCode,
|
||||
itemName: qi.itemName,
|
||||
type: qi.type,
|
||||
symbol: qi.symbol,
|
||||
spec: qi.spec,
|
||||
width: 0,
|
||||
height: 0,
|
||||
quantity: qi.quantity,
|
||||
unit: qi.unit,
|
||||
unitPrice: qi.unitPrice,
|
||||
amount: qi.amount,
|
||||
isFromQuotation: true, // 견적에서 가져온 품목 표시
|
||||
}));
|
||||
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
selectedQuotation: quotation,
|
||||
clientName: quotation.client,
|
||||
siteName: quotation.siteName,
|
||||
manager: quotation.manager || "",
|
||||
contact: quotation.contact || "",
|
||||
items,
|
||||
}));
|
||||
|
||||
toast.success("견적 정보가 불러와졌습니다.");
|
||||
};
|
||||
|
||||
// 견적 해제 핸들러
|
||||
const handleClearQuotation = () => {
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
selectedQuotation: undefined,
|
||||
// 기본 정보는 유지하고 품목만 초기화할지, 전체 초기화할지 선택 가능
|
||||
items: [],
|
||||
}));
|
||||
};
|
||||
|
||||
// 품목 추가 핸들러
|
||||
const handleAddItem = (item: OrderItem) => {
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
items: [...prev.items, item],
|
||||
}));
|
||||
// 품목 에러 초기화
|
||||
setFieldErrors((prev) => {
|
||||
if (prev.items) {
|
||||
const { items: _, ...rest } = prev;
|
||||
return rest;
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
toast.success("품목이 추가되었습니다.");
|
||||
};
|
||||
|
||||
// 품목 삭제 핸들러
|
||||
const handleRemoveItem = (itemId: string) => {
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
items: prev.items.filter((item) => item.id !== itemId),
|
||||
}));
|
||||
};
|
||||
|
||||
// 품목 수량 변경 핸들러
|
||||
const handleQuantityChange = (itemId: string, quantity: number) => {
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
items: prev.items.map((item) =>
|
||||
item.id === itemId
|
||||
? { ...item, quantity, amount: item.unitPrice * quantity }
|
||||
: item
|
||||
),
|
||||
}));
|
||||
};
|
||||
|
||||
// 유효성 검사 함수
|
||||
const validateForm = useCallback((): FieldErrors => {
|
||||
const errors: FieldErrors = {};
|
||||
|
||||
if (!form.clientName.trim()) {
|
||||
errors.clientName = "발주처를 선택해주세요.";
|
||||
}
|
||||
if (!form.siteName.trim()) {
|
||||
errors.siteName = "현장명을 입력해주세요.";
|
||||
}
|
||||
if (!form.deliveryRequestDate && !form.deliveryRequestDateUndecided) {
|
||||
errors.deliveryRequestDate = "납품요청일을 입력하거나 '미정'을 선택해주세요.";
|
||||
}
|
||||
if (!form.receiver.trim()) {
|
||||
errors.receiver = "수신자명을 입력해주세요.";
|
||||
}
|
||||
if (!form.receiverContact.trim()) {
|
||||
errors.receiverContact = "연락처를 입력해주세요.";
|
||||
}
|
||||
if (form.items.length === 0) {
|
||||
errors.items = "최소 1개 이상의 품목을 추가해주세요.";
|
||||
}
|
||||
|
||||
return errors;
|
||||
}, [form]);
|
||||
|
||||
// 필드 에러 초기화 (필드 값 변경 시)
|
||||
const clearFieldError = useCallback((field: keyof FieldErrors) => {
|
||||
setFieldErrors((prev) => {
|
||||
if (prev[field]) {
|
||||
const { [field]: _, ...rest } = prev;
|
||||
return rest;
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// 저장 핸들러
|
||||
const handleSave = async () => {
|
||||
// 유효성 검사
|
||||
const errors = validateForm();
|
||||
setFieldErrors(errors);
|
||||
|
||||
const errorCount = Object.keys(errors).length;
|
||||
if (errorCount > 0) {
|
||||
toast.error(`입력 내용을 확인해주세요. (${errorCount}개 오류)`);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await onSave(form);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<ResponsiveFormTemplate
|
||||
title={isEditMode ? "수주 수정" : "수주 등록"}
|
||||
description="견적을 수주로 전환하거나 새 수주를 등록합니다"
|
||||
icon={FileText}
|
||||
onSave={handleSave}
|
||||
onCancel={onBack}
|
||||
saveLabel="저장"
|
||||
cancelLabel="취소"
|
||||
saveLoading={isSaving}
|
||||
saveDisabled={isSaving}
|
||||
>
|
||||
{/* Validation 에러 Alert */}
|
||||
{Object.keys(fieldErrors).length > 0 && (
|
||||
<Alert className="bg-red-50 border-red-200">
|
||||
<AlertDescription className="text-red-900">
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="text-lg">⚠️</span>
|
||||
<div className="flex-1">
|
||||
<strong className="block mb-2">
|
||||
입력 내용을 확인해주세요 ({Object.keys(fieldErrors).length}개 오류)
|
||||
</strong>
|
||||
<ul className="space-y-1 text-sm">
|
||||
{Object.entries(fieldErrors).map(([field, error]) => {
|
||||
const fieldName = FIELD_NAME_MAP[field] || field;
|
||||
return (
|
||||
<li key={field} className="flex items-start gap-1">
|
||||
<span>•</span>
|
||||
<span>
|
||||
<strong>{fieldName}</strong>: {error}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* 견적 불러오기 섹션 */}
|
||||
<FormSection title="견적 불러오기" icon={Search}>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Info className="h-4 w-4" />
|
||||
확정된 견적을 선택하면 정보가 자동으로 채워집니다
|
||||
</div>
|
||||
|
||||
{form.selectedQuotation ? (
|
||||
<div className="p-4 border rounded-lg bg-muted/30">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="text-sm font-mono bg-gray-100 px-2 py-0.5 rounded">
|
||||
{form.selectedQuotation.quoteNumber}
|
||||
</code>
|
||||
<Badge variant="outline" className="bg-green-100 text-green-700 border-green-200">
|
||||
{form.selectedQuotation.grade} (우량)
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="text-sm">
|
||||
<span className="font-medium">{form.selectedQuotation.client}</span>
|
||||
<span className="text-muted-foreground mx-2">/</span>
|
||||
<span>{form.selectedQuotation.siteName}</span>
|
||||
<span className="text-muted-foreground mx-2">/</span>
|
||||
<span className="text-green-600 font-medium">
|
||||
{formatAmount(form.selectedQuotation.amount)}원
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleClearQuotation}
|
||||
>
|
||||
<X className="h-4 w-4 mr-1" />
|
||||
해제
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setIsQuotationDialogOpen(true)}
|
||||
>
|
||||
<Search className="h-4 w-4 mr-2" />
|
||||
견적 선택
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</FormSection>
|
||||
|
||||
{/* 기본 정보 섹션 */}
|
||||
<FormSection title="기본 정보" icon={FileText}>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>
|
||||
발주처 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Select
|
||||
value={form.clientId}
|
||||
onValueChange={(value) => {
|
||||
const client = SAMPLE_CLIENTS.find((c) => c.id === value);
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
clientId: value,
|
||||
clientName: client?.name || "",
|
||||
}));
|
||||
clearFieldError("clientName");
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className={cn(fieldErrors.clientName && "border-red-500")}>
|
||||
<SelectValue placeholder="발주처 선택">
|
||||
{form.clientName || "발주처 선택"}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SAMPLE_CLIENTS.map((client) => (
|
||||
<SelectItem key={client.id} value={client.id}>
|
||||
{client.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{fieldErrors.clientName && (
|
||||
<p className="text-sm text-red-500">{fieldErrors.clientName}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>
|
||||
현장명 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
placeholder="현장명 입력"
|
||||
value={form.siteName}
|
||||
onChange={(e) => {
|
||||
setForm((prev) => ({ ...prev, siteName: e.target.value }));
|
||||
clearFieldError("siteName");
|
||||
}}
|
||||
className={cn(fieldErrors.siteName && "border-red-500")}
|
||||
/>
|
||||
{fieldErrors.siteName && (
|
||||
<p className="text-sm text-red-500">{fieldErrors.siteName}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>담당자</Label>
|
||||
<Input
|
||||
placeholder="담당자명 입력"
|
||||
value={form.manager}
|
||||
onChange={(e) =>
|
||||
setForm((prev) => ({ ...prev, manager: e.target.value }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>연락처</Label>
|
||||
<Input
|
||||
placeholder="010-0000-0000"
|
||||
value={form.contact}
|
||||
onChange={(e) =>
|
||||
setForm((prev) => ({ ...prev, contact: e.target.value }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</FormSection>
|
||||
|
||||
{/* 수주/배송 정보 섹션 */}
|
||||
<FormSection title="수주/배송 정보">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* 출고예정일 */}
|
||||
<div className="space-y-2">
|
||||
<Label>출고예정일</Label>
|
||||
<div className="flex items-center gap-4">
|
||||
<Input
|
||||
type="date"
|
||||
value={form.expectedShipDate}
|
||||
onChange={(e) =>
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
expectedShipDate: e.target.value,
|
||||
}))
|
||||
}
|
||||
disabled={form.expectedShipDateUndecided}
|
||||
className="flex-1"
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="expectedShipDateUndecided"
|
||||
checked={form.expectedShipDateUndecided}
|
||||
onCheckedChange={(checked) =>
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
expectedShipDateUndecided: checked as boolean,
|
||||
expectedShipDate: checked ? "" : prev.expectedShipDate,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<Label
|
||||
htmlFor="expectedShipDateUndecided"
|
||||
className="text-sm font-normal"
|
||||
>
|
||||
미정
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 납품요청일 */}
|
||||
<div className="space-y-2">
|
||||
<Label>
|
||||
납품요청일 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<div className="flex items-center gap-4">
|
||||
<Input
|
||||
type="date"
|
||||
value={form.deliveryRequestDate}
|
||||
onChange={(e) => {
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
deliveryRequestDate: e.target.value,
|
||||
}));
|
||||
clearFieldError("deliveryRequestDate");
|
||||
}}
|
||||
disabled={form.deliveryRequestDateUndecided}
|
||||
className={cn("flex-1", fieldErrors.deliveryRequestDate && "border-red-500")}
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="deliveryRequestDateUndecided"
|
||||
checked={form.deliveryRequestDateUndecided}
|
||||
onCheckedChange={(checked) => {
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
deliveryRequestDateUndecided: checked as boolean,
|
||||
deliveryRequestDate: checked
|
||||
? ""
|
||||
: prev.deliveryRequestDate,
|
||||
}));
|
||||
if (checked) clearFieldError("deliveryRequestDate");
|
||||
}}
|
||||
/>
|
||||
<Label
|
||||
htmlFor="deliveryRequestDateUndecided"
|
||||
className="text-sm font-normal"
|
||||
>
|
||||
미정
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
{fieldErrors.deliveryRequestDate && (
|
||||
<p className="text-sm text-red-500">{fieldErrors.deliveryRequestDate}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 배송방식 */}
|
||||
<div className="space-y-2">
|
||||
<Label>배송방식</Label>
|
||||
<Select
|
||||
value={form.deliveryMethod}
|
||||
onValueChange={(value) =>
|
||||
setForm((prev) => ({ ...prev, deliveryMethod: value }))
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="배송방식 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{DELIVERY_METHODS.map((method) => (
|
||||
<SelectItem key={method.value} value={method.value}>
|
||||
{method.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 운임비용 */}
|
||||
<div className="space-y-2">
|
||||
<Label>운임비용</Label>
|
||||
<Select
|
||||
value={form.shippingCost}
|
||||
onValueChange={(value) =>
|
||||
setForm((prev) => ({ ...prev, shippingCost: value }))
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="운임비용 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SHIPPING_COSTS.map((cost) => (
|
||||
<SelectItem key={cost.value} value={cost.value}>
|
||||
{cost.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 수신(반장/업체) */}
|
||||
<div className="space-y-2">
|
||||
<Label>
|
||||
수신(반장/업체) <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
placeholder="수신자명 입력"
|
||||
value={form.receiver}
|
||||
onChange={(e) => {
|
||||
setForm((prev) => ({ ...prev, receiver: e.target.value }));
|
||||
clearFieldError("receiver");
|
||||
}}
|
||||
className={cn(fieldErrors.receiver && "border-red-500")}
|
||||
/>
|
||||
{fieldErrors.receiver && (
|
||||
<p className="text-sm text-red-500">{fieldErrors.receiver}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 수신처 연락처 */}
|
||||
<div className="space-y-2">
|
||||
<Label>
|
||||
수신처 연락처 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
placeholder="010-0000-0000"
|
||||
value={form.receiverContact}
|
||||
onChange={(e) => {
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
receiverContact: e.target.value,
|
||||
}));
|
||||
clearFieldError("receiverContact");
|
||||
}}
|
||||
className={cn(fieldErrors.receiverContact && "border-red-500")}
|
||||
/>
|
||||
{fieldErrors.receiverContact && (
|
||||
<p className="text-sm text-red-500">{fieldErrors.receiverContact}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</FormSection>
|
||||
|
||||
{/* 수신처 주소 섹션 */}
|
||||
<FormSection title="수신처 주소">
|
||||
<div className="space-y-4">
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder="우편번호"
|
||||
value={form.zipCode}
|
||||
onChange={(e) =>
|
||||
setForm((prev) => ({ ...prev, zipCode: e.target.value }))
|
||||
}
|
||||
className="w-32"
|
||||
/>
|
||||
<Button variant="outline" type="button">
|
||||
우편번호 찾기
|
||||
</Button>
|
||||
</div>
|
||||
<Input
|
||||
placeholder="기본 주소"
|
||||
value={form.address}
|
||||
onChange={(e) =>
|
||||
setForm((prev) => ({ ...prev, address: e.target.value }))
|
||||
}
|
||||
/>
|
||||
<Input
|
||||
placeholder="상세 주소 입력"
|
||||
value={form.addressDetail}
|
||||
onChange={(e) =>
|
||||
setForm((prev) => ({ ...prev, addressDetail: e.target.value }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</FormSection>
|
||||
|
||||
{/* 비고 섹션 */}
|
||||
<FormSection title="비고">
|
||||
<Textarea
|
||||
placeholder="특이사항을 입력하세요"
|
||||
value={form.remarks}
|
||||
onChange={(e) =>
|
||||
setForm((prev) => ({ ...prev, remarks: e.target.value }))
|
||||
}
|
||||
rows={4}
|
||||
/>
|
||||
</FormSection>
|
||||
|
||||
{/* 품목 내역 섹션 */}
|
||||
<FormSection title="품목 내역">
|
||||
<div className="space-y-4">
|
||||
{/* 품목 에러 메시지 */}
|
||||
{fieldErrors.items && (
|
||||
<p className="text-sm text-red-500">{fieldErrors.items}</p>
|
||||
)}
|
||||
{/* 품목 테이블 */}
|
||||
<div className={cn("border rounded-lg overflow-hidden", fieldErrors.items && "border-red-500")}>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[60px] text-center">순번</TableHead>
|
||||
<TableHead>품목코드</TableHead>
|
||||
<TableHead>품명</TableHead>
|
||||
<TableHead>층</TableHead>
|
||||
<TableHead>부호</TableHead>
|
||||
<TableHead>규격</TableHead>
|
||||
<TableHead className="w-[80px] text-center">수량</TableHead>
|
||||
<TableHead className="w-[60px] text-center">단위</TableHead>
|
||||
<TableHead className="text-right">단가</TableHead>
|
||||
<TableHead className="text-right">금액</TableHead>
|
||||
<TableHead className="w-[60px]"></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{form.items.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={11} className="text-center py-8 text-muted-foreground">
|
||||
품목이 없습니다. 견적을 선택하거나 품목을 추가해주세요.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
form.items.map((item, index) => (
|
||||
<TableRow key={item.id}>
|
||||
<TableCell className="text-center">{index + 1}</TableCell>
|
||||
<TableCell>
|
||||
<code className="text-xs bg-gray-100 px-1.5 py-0.5 rounded">
|
||||
{item.itemCode}
|
||||
</code>
|
||||
</TableCell>
|
||||
<TableCell>{item.itemName}</TableCell>
|
||||
<TableCell>{item.type || "-"}</TableCell>
|
||||
<TableCell>{item.symbol || "-"}</TableCell>
|
||||
<TableCell>{item.spec}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={item.quantity}
|
||||
onChange={(e) =>
|
||||
handleQuantityChange(
|
||||
item.id,
|
||||
Number(e.target.value) || 1
|
||||
)
|
||||
}
|
||||
className="w-16 text-center"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">{item.unit}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{formatAmount(item.unitPrice)}원
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-medium">
|
||||
{formatAmount(item.amount)}원
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleRemoveItem(item.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-red-500" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* 품목 추가 버튼 */}
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setIsItemDialogOpen(true)}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
품목 추가
|
||||
</Button>
|
||||
|
||||
{/* 합계 */}
|
||||
<div className="flex flex-col items-end gap-2 pt-4 border-t">
|
||||
<div className="flex items-center gap-4 text-sm">
|
||||
<span className="text-muted-foreground">소계:</span>
|
||||
<span className="w-32 text-right">
|
||||
{formatAmount(form.subtotal)}원
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-sm">
|
||||
<span className="text-muted-foreground">할인율(%):</span>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
value={form.discountRate}
|
||||
onChange={(e) =>
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
discountRate: Number(e.target.value) || 0,
|
||||
}))
|
||||
}
|
||||
className="w-20 text-right"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-lg font-semibold">
|
||||
<span>총금액:</span>
|
||||
<span className="w-32 text-right text-green-600">
|
||||
{formatAmount(form.totalAmount)}원
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</FormSection>
|
||||
</ResponsiveFormTemplate>
|
||||
|
||||
{/* 견적 선택 팝업 */}
|
||||
<QuotationSelectDialog
|
||||
open={isQuotationDialogOpen}
|
||||
onOpenChange={setIsQuotationDialogOpen}
|
||||
onSelect={handleQuotationSelect}
|
||||
selectedId={form.selectedQuotation?.id}
|
||||
/>
|
||||
|
||||
{/* 품목 추가 팝업 */}
|
||||
<ItemAddDialog
|
||||
open={isItemDialogOpen}
|
||||
onOpenChange={setIsItemDialogOpen}
|
||||
onAdd={handleAddItem}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
261
src/components/orders/QuotationSelectDialog.tsx
Normal file
261
src/components/orders/QuotationSelectDialog.tsx
Normal file
@@ -0,0 +1,261 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 견적 선택 팝업
|
||||
*
|
||||
* 확정된 견적 목록에서 수주 전환할 견적을 선택하는 다이얼로그
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Search, FileText, Check } from "lucide-react";
|
||||
import { formatAmount } from "@/utils/formatAmount";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// 견적 타입
|
||||
export interface QuotationForSelect {
|
||||
id: string;
|
||||
quoteNumber: string; // KD-PR-XXXXXX-XX
|
||||
grade: string; // A(우량), B(관리), C(주의)
|
||||
client: string; // 발주처
|
||||
siteName: string; // 현장명
|
||||
amount: number; // 총 금액
|
||||
itemCount: number; // 품목 수
|
||||
registrationDate: string; // 견적일
|
||||
manager?: string; // 담당자
|
||||
contact?: string; // 연락처
|
||||
items?: QuotationItem[]; // 품목 내역
|
||||
}
|
||||
|
||||
export interface QuotationItem {
|
||||
id: string;
|
||||
itemCode: string;
|
||||
itemName: string;
|
||||
type: string; // 종
|
||||
symbol: string; // 부호
|
||||
spec: string; // 규격
|
||||
quantity: number;
|
||||
unit: string;
|
||||
unitPrice: number;
|
||||
amount: number;
|
||||
}
|
||||
|
||||
interface QuotationSelectDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSelect: (quotation: QuotationForSelect) => void;
|
||||
selectedId?: string;
|
||||
}
|
||||
|
||||
// 샘플 견적 데이터 (실제 구현에서는 API 연동)
|
||||
const SAMPLE_QUOTATIONS: QuotationForSelect[] = [
|
||||
{
|
||||
id: "QT-001",
|
||||
quoteNumber: "KD-PR-251210-01",
|
||||
grade: "A",
|
||||
client: "태영건설(주)",
|
||||
siteName: "데시앙 동탄 파크뷰",
|
||||
amount: 38800000,
|
||||
itemCount: 5,
|
||||
registrationDate: "2024-12-10",
|
||||
manager: "김철수",
|
||||
contact: "010-1234-5678",
|
||||
items: [
|
||||
{ id: "1", itemCode: "PRD-001", itemName: "국민방화스크린세터", type: "B1", symbol: "FSS1", spec: "7260×2600", quantity: 2, unit: "EA", unitPrice: 8000000, amount: 16000000 },
|
||||
{ id: "2", itemCode: "PRD-002", itemName: "국민방화스크린세터", type: "B1", symbol: "FSS2", spec: "5000×2400", quantity: 3, unit: "EA", unitPrice: 7600000, amount: 22800000 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "QT-002",
|
||||
quoteNumber: "KD-PR-251211-02",
|
||||
grade: "A",
|
||||
client: "현대건설(주)",
|
||||
siteName: "힐스테이트 판교역",
|
||||
amount: 52500000,
|
||||
itemCount: 8,
|
||||
registrationDate: "2024-12-11",
|
||||
manager: "이영희",
|
||||
contact: "010-2345-6789",
|
||||
items: [
|
||||
{ id: "1", itemCode: "PRD-003", itemName: "국민방화스크린세터", type: "B2", symbol: "FSS1", spec: "6000×3000", quantity: 4, unit: "EA", unitPrice: 9500000, amount: 38000000 },
|
||||
{ id: "2", itemCode: "PRD-004", itemName: "국민방화스크린세터", type: "B1", symbol: "FSS2", spec: "4500×2500", quantity: 2, unit: "EA", unitPrice: 7250000, amount: 14500000 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "QT-003",
|
||||
quoteNumber: "KD-PR-251208-03",
|
||||
grade: "B",
|
||||
client: "GS건설(주)",
|
||||
siteName: "자이 강남센터",
|
||||
amount: 45000000,
|
||||
itemCount: 6,
|
||||
registrationDate: "2024-12-08",
|
||||
manager: "박민수",
|
||||
contact: "010-3456-7890",
|
||||
items: [],
|
||||
},
|
||||
{
|
||||
id: "QT-004",
|
||||
quoteNumber: "KD-PR-251205-04",
|
||||
grade: "B",
|
||||
client: "대우건설(주)",
|
||||
siteName: "푸르지오 송도",
|
||||
amount: 28900000,
|
||||
itemCount: 4,
|
||||
registrationDate: "2024-12-05",
|
||||
manager: "최지원",
|
||||
contact: "010-4567-8901",
|
||||
items: [],
|
||||
},
|
||||
{
|
||||
id: "QT-005",
|
||||
quoteNumber: "KD-PR-251201-05",
|
||||
grade: "A",
|
||||
client: "포스코건설",
|
||||
siteName: "더샵 분당센트럴",
|
||||
amount: 62000000,
|
||||
itemCount: 10,
|
||||
registrationDate: "2024-12-01",
|
||||
manager: "정수민",
|
||||
contact: "010-5678-9012",
|
||||
items: [],
|
||||
},
|
||||
];
|
||||
|
||||
// 등급 배지 컴포넌트
|
||||
function GradeBadge({ grade }: { grade: string }) {
|
||||
const config: Record<string, { label: string; className: string }> = {
|
||||
A: { label: "A (우량)", className: "bg-green-100 text-green-700 border-green-200" },
|
||||
B: { label: "B (관리)", className: "bg-yellow-100 text-yellow-700 border-yellow-200" },
|
||||
C: { label: "C (주의)", className: "bg-red-100 text-red-700 border-red-200" },
|
||||
};
|
||||
const cfg = config[grade] || config.B;
|
||||
return (
|
||||
<Badge variant="outline" className={cn("text-xs", cfg.className)}>
|
||||
{cfg.label}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
export function QuotationSelectDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onSelect,
|
||||
selectedId,
|
||||
}: QuotationSelectDialogProps) {
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [quotations] = useState<QuotationForSelect[]>(SAMPLE_QUOTATIONS);
|
||||
|
||||
// 검색 필터링
|
||||
const filteredQuotations = quotations.filter((q) => {
|
||||
const searchLower = searchTerm.toLowerCase();
|
||||
return (
|
||||
!searchTerm ||
|
||||
q.quoteNumber.toLowerCase().includes(searchLower) ||
|
||||
q.client.toLowerCase().includes(searchLower) ||
|
||||
q.siteName.toLowerCase().includes(searchLower)
|
||||
);
|
||||
});
|
||||
|
||||
// 다이얼로그 열릴 때 검색어 초기화
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setSearchTerm("");
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const handleSelect = (quotation: QuotationForSelect) => {
|
||||
onSelect(quotation);
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl max-h-[80vh] overflow-hidden flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<FileText className="h-5 w-5" />
|
||||
견적 선택
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{/* 검색창 */}
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="견적번호, 거래처, 현장명 검색..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 안내 문구 */}
|
||||
<div className="text-sm text-muted-foreground">
|
||||
전환 가능한 견적 {filteredQuotations.length}건 (최종확정 상태)
|
||||
</div>
|
||||
|
||||
{/* 견적 목록 */}
|
||||
<div className="flex-1 overflow-y-auto space-y-3 pr-2">
|
||||
{filteredQuotations.map((quotation) => (
|
||||
<div
|
||||
key={quotation.id}
|
||||
onClick={() => handleSelect(quotation)}
|
||||
className={cn(
|
||||
"p-4 border rounded-lg cursor-pointer transition-colors",
|
||||
"hover:bg-muted/50 hover:border-primary/50",
|
||||
selectedId === quotation.id && "border-primary bg-primary/5"
|
||||
)}
|
||||
>
|
||||
{/* 상단: 견적번호 + 등급 */}
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="text-sm font-mono bg-gray-100 px-2 py-0.5 rounded">
|
||||
{quotation.quoteNumber}
|
||||
</code>
|
||||
<GradeBadge grade={quotation.grade} />
|
||||
</div>
|
||||
{selectedId === quotation.id && (
|
||||
<Check className="h-5 w-5 text-primary" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 발주처 */}
|
||||
<div className="font-medium text-base mb-1">
|
||||
{quotation.client}
|
||||
</div>
|
||||
|
||||
{/* 현장명 + 금액 */}
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">
|
||||
[{quotation.siteName}]
|
||||
</span>
|
||||
<span className="font-medium text-green-600">
|
||||
{formatAmount(quotation.amount)}원
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 품목 수 */}
|
||||
<div className="text-xs text-muted-foreground mt-1 text-right">
|
||||
{quotation.itemCount}개 품목
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{filteredQuotations.length === 0 && (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
검색 결과가 없습니다.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
212
src/components/orders/documents/ContractDocument.tsx
Normal file
212
src/components/orders/documents/ContractDocument.tsx
Normal file
@@ -0,0 +1,212 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 계약서 문서 컴포넌트
|
||||
* - 스크린샷 형식 + 지출결의서 디자인 스타일
|
||||
*/
|
||||
|
||||
import { formatAmount } from "@/utils/formatAmount";
|
||||
import { OrderItem } from "@/components/orders";
|
||||
|
||||
interface ContractDocumentProps {
|
||||
orderNumber: string;
|
||||
orderDate: string;
|
||||
client: string;
|
||||
clientBusinessNumber?: string;
|
||||
clientCeo?: string;
|
||||
clientContact?: string;
|
||||
clientAddress?: string;
|
||||
companyName?: string;
|
||||
companyCeo?: string;
|
||||
companyBusinessNumber?: string;
|
||||
companyContact?: string;
|
||||
companyAddress?: string;
|
||||
items: OrderItem[];
|
||||
subtotal: number;
|
||||
discountRate: number;
|
||||
totalAmount: number;
|
||||
remarks?: string;
|
||||
}
|
||||
|
||||
export function ContractDocument({
|
||||
orderNumber,
|
||||
orderDate,
|
||||
client,
|
||||
clientBusinessNumber = "123-45-67890",
|
||||
clientCeo = "대표자",
|
||||
clientContact = "02-1234-5678",
|
||||
clientAddress = "서울시 강남구",
|
||||
companyName = "(주)케이디산업",
|
||||
companyCeo = "김대표",
|
||||
companyBusinessNumber = "111-22-33333",
|
||||
companyContact = "02-9999-8888",
|
||||
companyAddress = "경기도 화성시 케이디로 123",
|
||||
items,
|
||||
subtotal,
|
||||
discountRate,
|
||||
totalAmount,
|
||||
remarks,
|
||||
}: ContractDocumentProps) {
|
||||
const discountAmount = Math.round(subtotal * (discountRate / 100));
|
||||
const afterDiscount = subtotal - discountAmount;
|
||||
const vat = Math.round(afterDiscount * 0.1);
|
||||
const finalTotal = afterDiscount + vat;
|
||||
|
||||
return (
|
||||
<div className="bg-white p-8 min-h-full">
|
||||
{/* 제목 */}
|
||||
<div className="text-center mb-6">
|
||||
<h1 className="text-2xl font-bold tracking-widest mb-2">계 약 서</h1>
|
||||
<p className="text-sm text-gray-600">
|
||||
수주번호: {orderNumber} | 계약일자: {orderDate}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 제품명 */}
|
||||
<div className="border border-gray-300 mb-4">
|
||||
<div className="bg-gray-800 text-white p-2 text-sm font-medium text-center">
|
||||
제품명
|
||||
</div>
|
||||
<div className="p-3 text-center text-sm">
|
||||
스크린 세터 (표준형)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 수주물목 테이블 */}
|
||||
<div className="border border-gray-300 mb-4">
|
||||
<div className="bg-gray-800 text-white p-2 text-sm font-medium text-center">
|
||||
수주물목 (개소별 사이즈)
|
||||
</div>
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="bg-gray-100 border-b border-gray-300">
|
||||
<th className="p-2 text-center font-medium border-r border-gray-300 w-24">품목코드</th>
|
||||
<th className="p-2 text-left font-medium border-r border-gray-300">품명</th>
|
||||
<th className="p-2 text-center font-medium border-r border-gray-300 w-28">규격</th>
|
||||
<th className="p-2 text-center font-medium border-r border-gray-300 w-16">수량</th>
|
||||
<th className="p-2 text-center font-medium w-16">단위</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.length > 0 ? (
|
||||
items.map((item) => (
|
||||
<tr key={item.id} className="border-b border-gray-300">
|
||||
<td className="p-2 text-center border-r border-gray-300">{item.itemCode}</td>
|
||||
<td className="p-2 border-r border-gray-300">{item.itemName}</td>
|
||||
<td className="p-2 text-center border-r border-gray-300">{item.spec}</td>
|
||||
<td className="p-2 text-center border-r border-gray-300">{item.quantity}</td>
|
||||
<td className="p-2 text-center">{item.unit}</td>
|
||||
</tr>
|
||||
))
|
||||
) : (
|
||||
<tr className="border-b border-gray-300">
|
||||
<td colSpan={5} className="p-4 text-center text-gray-400">
|
||||
등록된 품목이 없습니다
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* 발주처/당사 정보 */}
|
||||
<div className="grid grid-cols-2 gap-4 mb-4">
|
||||
<div className="border border-gray-300">
|
||||
<div className="bg-gray-800 text-white p-2 text-sm font-medium text-center">
|
||||
발주처정보
|
||||
</div>
|
||||
<div className="p-3 space-y-1 text-sm">
|
||||
<div className="flex">
|
||||
<span className="w-20 text-gray-600">업체명</span>
|
||||
<span>{client}</span>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<span className="w-20 text-gray-600">현장명</span>
|
||||
<span>-</span>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<span className="w-20 text-gray-600">담당자</span>
|
||||
<span>{clientCeo}</span>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<span className="w-20 text-gray-600">연락처</span>
|
||||
<span>{clientContact}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border border-gray-300">
|
||||
<div className="bg-gray-800 text-white p-2 text-sm font-medium text-center">
|
||||
당사정보
|
||||
</div>
|
||||
<div className="p-3 space-y-1 text-sm">
|
||||
<div className="flex">
|
||||
<span className="w-20 text-gray-600">업체명</span>
|
||||
<span>{companyName}</span>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<span className="w-20 text-gray-600">대표자</span>
|
||||
<span>{companyCeo}</span>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<span className="w-20 text-gray-600">사업자번호</span>
|
||||
<span>{companyBusinessNumber}</span>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<span className="w-20 text-gray-600">연락처</span>
|
||||
<span>{companyContact}</span>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<span className="w-20 text-gray-600">주소</span>
|
||||
<span>{companyAddress}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 총 계약 금액 */}
|
||||
<div className="border border-gray-300 p-6 text-center mb-4">
|
||||
<p className="text-sm text-gray-600 mb-2">총 계약 금액</p>
|
||||
<p className="text-3xl font-bold">
|
||||
₩ {formatAmount(finalTotal)}
|
||||
</p>
|
||||
<p className="text-sm text-gray-600 mt-1">(부가세 포함)</p>
|
||||
</div>
|
||||
|
||||
{/* 금액 계산 */}
|
||||
<div className="border border-gray-300 mb-4">
|
||||
<table className="w-full text-sm">
|
||||
<tbody>
|
||||
<tr className="border-b border-gray-300">
|
||||
<td className="p-2 bg-gray-100 border-r border-gray-300 w-32">공급가액</td>
|
||||
<td className="p-2 text-right border-r border-gray-300">{formatAmount(subtotal)}원</td>
|
||||
<td className="p-2 bg-gray-100 border-r border-gray-300 w-32">할인율</td>
|
||||
<td className="p-2 text-right">{discountRate}%</td>
|
||||
</tr>
|
||||
<tr className="border-b border-gray-300">
|
||||
<td className="p-2 bg-gray-100 border-r border-gray-300">할인액</td>
|
||||
<td className="p-2 text-right border-r border-gray-300 text-red-600">-{formatAmount(discountAmount)}원</td>
|
||||
<td className="p-2 bg-gray-100 border-r border-gray-300">할인 후 공급가액</td>
|
||||
<td className="p-2 text-right">{formatAmount(afterDiscount)}원</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="p-2 bg-gray-100 border-r border-gray-300">부가세(10%)</td>
|
||||
<td className="p-2 text-right border-r border-gray-300">{formatAmount(vat)}원</td>
|
||||
<td className="p-2 bg-gray-100 border-r border-gray-300 font-medium">합계</td>
|
||||
<td className="p-2 text-right font-semibold">{formatAmount(finalTotal)}원</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* 특이사항 */}
|
||||
<div className="border border-gray-300">
|
||||
<div className="bg-gray-800 text-white p-2 text-sm font-medium text-center">
|
||||
특이사항
|
||||
</div>
|
||||
<div className="p-3 min-h-[60px] text-sm">
|
||||
{remarks || "-"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
197
src/components/orders/documents/OrderDocumentModal.tsx
Normal file
197
src/components/orders/documents/OrderDocumentModal.tsx
Normal file
@@ -0,0 +1,197 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 수주 문서 모달 컴포넌트
|
||||
* - 계약서, 거래명세서, 발주서를 모달 형태로 표시
|
||||
*/
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
VisuallyHidden,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
X as XIcon,
|
||||
Printer,
|
||||
Share2,
|
||||
FileDown,
|
||||
Mail,
|
||||
Phone,
|
||||
} from "lucide-react";
|
||||
import { ContractDocument } from "./ContractDocument";
|
||||
import { TransactionDocument } from "./TransactionDocument";
|
||||
import { PurchaseOrderDocument } from "./PurchaseOrderDocument";
|
||||
import { OrderItem } from "../ItemAddDialog";
|
||||
|
||||
// 문서 타입
|
||||
export type OrderDocumentType = "contract" | "transaction" | "purchaseOrder";
|
||||
|
||||
// 문서 데이터 인터페이스
|
||||
export interface OrderDocumentData {
|
||||
lotNumber: string;
|
||||
orderDate: string;
|
||||
client: string;
|
||||
siteName: string;
|
||||
manager: string;
|
||||
managerContact: string;
|
||||
deliveryRequestDate: string;
|
||||
expectedShipDate: string;
|
||||
deliveryMethod: string;
|
||||
address: string;
|
||||
items: OrderItem[];
|
||||
subtotal: number;
|
||||
discountRate: number;
|
||||
totalAmount: number;
|
||||
remarks?: string;
|
||||
}
|
||||
|
||||
interface OrderDocumentModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
documentType: OrderDocumentType;
|
||||
data: OrderDocumentData;
|
||||
}
|
||||
|
||||
export function OrderDocumentModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
documentType,
|
||||
data,
|
||||
}: OrderDocumentModalProps) {
|
||||
const getDocumentTitle = () => {
|
||||
switch (documentType) {
|
||||
case "contract":
|
||||
return "계약서";
|
||||
case "transaction":
|
||||
return "거래명세서";
|
||||
case "purchaseOrder":
|
||||
return "발주서";
|
||||
default:
|
||||
return "문서";
|
||||
}
|
||||
};
|
||||
|
||||
const handlePrint = () => {
|
||||
window.print();
|
||||
};
|
||||
|
||||
const handleSharePdf = () => {
|
||||
console.log("PDF 다운로드");
|
||||
};
|
||||
|
||||
const handleShareEmail = () => {
|
||||
console.log("이메일 공유");
|
||||
};
|
||||
|
||||
const handleShareFax = () => {
|
||||
console.log("팩스 전송");
|
||||
};
|
||||
|
||||
const handleShareKakao = () => {
|
||||
console.log("카카오톡 공유");
|
||||
};
|
||||
|
||||
const renderDocument = () => {
|
||||
switch (documentType) {
|
||||
case "contract":
|
||||
return (
|
||||
<ContractDocument
|
||||
orderNumber={data.lotNumber}
|
||||
orderDate={data.orderDate}
|
||||
client={data.client}
|
||||
items={data.items}
|
||||
subtotal={data.subtotal}
|
||||
discountRate={data.discountRate}
|
||||
totalAmount={data.totalAmount}
|
||||
remarks={data.remarks}
|
||||
/>
|
||||
);
|
||||
case "transaction":
|
||||
return (
|
||||
<TransactionDocument
|
||||
orderNumber={data.lotNumber}
|
||||
orderDate={data.orderDate}
|
||||
client={data.client}
|
||||
items={data.items}
|
||||
subtotal={data.subtotal}
|
||||
discountRate={data.discountRate}
|
||||
totalAmount={data.totalAmount}
|
||||
/>
|
||||
);
|
||||
case "purchaseOrder":
|
||||
return (
|
||||
<PurchaseOrderDocument
|
||||
orderNumber={data.lotNumber}
|
||||
client={data.client}
|
||||
siteName={data.siteName}
|
||||
manager={data.manager}
|
||||
managerContact={data.managerContact}
|
||||
deliveryRequestDate={data.deliveryRequestDate}
|
||||
expectedShipDate={data.expectedShipDate}
|
||||
deliveryMethod={data.deliveryMethod}
|
||||
address={data.address}
|
||||
items={data.items}
|
||||
remarks={data.remarks}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-[95vw] md:max-w-[800px] lg:max-w-[900px] h-[95vh] p-0 flex flex-col gap-0 overflow-hidden [&>button]:hidden">
|
||||
<VisuallyHidden>
|
||||
<DialogTitle>{getDocumentTitle()} 상세</DialogTitle>
|
||||
</VisuallyHidden>
|
||||
|
||||
{/* 헤더 영역 - 고정 */}
|
||||
<div className="flex-shrink-0 flex items-center justify-between px-6 py-4 border-b bg-white">
|
||||
<h2 className="text-lg font-semibold">{getDocumentTitle()} 상세</h2>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => onOpenChange(false)}
|
||||
className="h-8 w-8"
|
||||
>
|
||||
<XIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 버튼 영역 - 고정 */}
|
||||
<div className="flex-shrink-0 flex items-center gap-2 px-6 py-3 bg-muted/30 border-b">
|
||||
<Button variant="outline" size="sm" onClick={handleSharePdf}>
|
||||
<FileDown className="h-4 w-4 mr-1" />
|
||||
PDF
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={handleShareEmail}>
|
||||
<Mail className="h-4 w-4 mr-1" />
|
||||
이메일
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={handleShareFax}>
|
||||
<Phone className="h-4 w-4 mr-1" />
|
||||
팩스
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={handleShareKakao}>
|
||||
<Share2 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="flex-1 overflow-y-auto bg-gray-100 p-6">
|
||||
<div className="max-w-[210mm] mx-auto bg-white shadow-lg rounded-lg">
|
||||
{renderDocument()}
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
202
src/components/orders/documents/PurchaseOrderDocument.tsx
Normal file
202
src/components/orders/documents/PurchaseOrderDocument.tsx
Normal file
@@ -0,0 +1,202 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 발주서 문서 컴포넌트
|
||||
* - 스크린샷 형식 + 지출결의서 디자인 스타일
|
||||
*/
|
||||
|
||||
import { OrderItem } from "@/components/orders";
|
||||
|
||||
interface PurchaseOrderDocumentProps {
|
||||
orderNumber: string;
|
||||
client: string;
|
||||
siteName: string;
|
||||
manager?: string;
|
||||
managerContact?: string;
|
||||
deliveryRequestDate: string;
|
||||
expectedShipDate?: string;
|
||||
deliveryMethod?: string;
|
||||
address: string;
|
||||
orderDate?: string;
|
||||
installationCount?: number;
|
||||
items: OrderItem[];
|
||||
remarks?: string;
|
||||
}
|
||||
|
||||
export function PurchaseOrderDocument({
|
||||
orderNumber,
|
||||
client,
|
||||
siteName,
|
||||
manager = "-",
|
||||
managerContact = "010-0123-4567",
|
||||
deliveryRequestDate,
|
||||
expectedShipDate = "-",
|
||||
deliveryMethod = "상차",
|
||||
address,
|
||||
orderDate = new Date().toISOString().split("T")[0],
|
||||
installationCount = 3,
|
||||
items,
|
||||
remarks,
|
||||
}: PurchaseOrderDocumentProps) {
|
||||
return (
|
||||
<div className="bg-white p-8 min-h-full">
|
||||
{/* 헤더: 제목 + 로트번호/결재란 */}
|
||||
<div className="flex justify-between items-start mb-6">
|
||||
<h1 className="text-2xl font-bold tracking-widest">발 주 서</h1>
|
||||
<div className="text-right">
|
||||
<div className="flex items-center justify-end gap-2 mb-2 text-sm">
|
||||
<span className="text-gray-600">로트번호</span>
|
||||
<span className="font-medium">{orderNumber}</span>
|
||||
</div>
|
||||
<table className="border border-gray-300 text-xs">
|
||||
<thead>
|
||||
<tr className="bg-gray-100">
|
||||
<th className="border-r border-gray-300 px-2 py-1">결재</th>
|
||||
<th className="border-r border-gray-300 px-2 py-1">작성</th>
|
||||
<th className="border-r border-gray-300 px-2 py-1">검토</th>
|
||||
<th className="border-r border-gray-300 px-2 py-1">승인</th>
|
||||
<th className="border-r border-gray-300 px-2 py-1">전결</th>
|
||||
<th className="border-r border-gray-300 px-2 py-1">회계</th>
|
||||
<th className="px-2 py-1">생산</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td className="border-r border-t border-gray-300 h-8 w-10"></td>
|
||||
<td className="border-r border-t border-gray-300 h-8 w-10"></td>
|
||||
<td className="border-r border-t border-gray-300 h-8 w-10"></td>
|
||||
<td className="border-r border-t border-gray-300 h-8 w-10"></td>
|
||||
<td className="border-r border-t border-gray-300 h-8 w-10"></td>
|
||||
<td className="border-r border-t border-gray-300 h-8 w-10"></td>
|
||||
<td className="border-t border-gray-300 h-8 w-10"></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 신청업체 */}
|
||||
<div className="border border-gray-300 mb-4">
|
||||
<table className="w-full text-sm">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td rowSpan={2} className="bg-gray-100 border-r border-b border-gray-300 p-2 text-center font-medium w-20">
|
||||
신청업체
|
||||
</td>
|
||||
<td className="bg-gray-100 border-r border-b border-gray-300 p-2 w-20">발주처</td>
|
||||
<td className="border-r border-b border-gray-300 p-2">{client}</td>
|
||||
<td className="bg-gray-100 border-r border-b border-gray-300 p-2 w-20">발주일</td>
|
||||
<td className="border-b border-gray-300 p-2">{orderDate}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="bg-gray-100 border-r border-b border-gray-300 p-2">담당자</td>
|
||||
<td className="border-r border-b border-gray-300 p-2">{manager}</td>
|
||||
<td className="bg-gray-100 border-r border-b border-gray-300 p-2">연락처</td>
|
||||
<td className="border-b border-gray-300 p-2">{managerContact}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="bg-gray-100 border-r border-gray-300 p-2 text-center font-medium"></td>
|
||||
<td className="bg-gray-100 border-r border-gray-300 p-2">FAX</td>
|
||||
<td className="border-r border-gray-300 p-2">-</td>
|
||||
<td className="bg-gray-100 border-r border-gray-300 p-2">설치개소<br/>(동)</td>
|
||||
<td className="border-gray-300 p-2">{installationCount}개소</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* 신청내용 */}
|
||||
<div className="border border-gray-300 mb-4">
|
||||
<table className="w-full text-sm">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td rowSpan={3} className="bg-gray-100 border-r border-gray-300 p-2 text-center font-medium w-20">
|
||||
신청내용
|
||||
</td>
|
||||
<td className="bg-gray-100 border-r border-b border-gray-300 p-2 w-20">현장명</td>
|
||||
<td colSpan={3} className="border-b border-gray-300 p-2">{siteName}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="bg-gray-100 border-r border-b border-gray-300 p-2">납기요청<br/>일</td>
|
||||
<td className="border-r border-b border-gray-300 p-2">{deliveryRequestDate}</td>
|
||||
<td className="bg-gray-100 border-r border-b border-gray-300 p-2 w-20">배송방법</td>
|
||||
<td className="border-b border-gray-300 p-2">{deliveryMethod}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="bg-gray-100 border-r border-gray-300 p-2">출고일</td>
|
||||
<td className="border-r border-gray-300 p-2">{expectedShipDate}</td>
|
||||
<td className="bg-gray-100 border-r border-gray-300 p-2">납품주소</td>
|
||||
<td className="border-gray-300 p-2">{address}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* 부자재 */}
|
||||
<div className="mb-4">
|
||||
<p className="text-sm font-medium mb-2">■ 부자재</p>
|
||||
<div className="border border-gray-300">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="bg-gray-100 border-b border-gray-300">
|
||||
<th className="p-2 text-center font-medium border-r border-gray-300 w-16">구분</th>
|
||||
<th className="p-2 text-left font-medium border-r border-gray-300">품명</th>
|
||||
<th className="p-2 text-center font-medium border-r border-gray-300 w-20">규격</th>
|
||||
<th className="p-2 text-center font-medium border-r border-gray-300 w-24">길이(mm)</th>
|
||||
<th className="p-2 text-center font-medium border-r border-gray-300 w-16">수량</th>
|
||||
<th className="p-2 text-center font-medium w-24">비고</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.length > 0 ? (
|
||||
items.map((item, index) => (
|
||||
<tr key={item.id} className="border-b border-gray-300">
|
||||
<td className="p-2 text-center border-r border-gray-300">{index + 1}</td>
|
||||
<td className="p-2 border-r border-gray-300">{item.itemName}</td>
|
||||
<td className="p-2 text-center border-r border-gray-300">{item.spec}</td>
|
||||
<td className="p-2 text-center border-r border-gray-300">
|
||||
{item.width ? `${item.width}` : "-"}
|
||||
</td>
|
||||
<td className="p-2 text-center border-r border-gray-300">{item.quantity}</td>
|
||||
<td className="p-2 text-center">{item.symbol || "-"}</td>
|
||||
</tr>
|
||||
))
|
||||
) : (
|
||||
<tr className="border-b border-gray-300">
|
||||
<td colSpan={6} className="p-4 text-center text-gray-400">
|
||||
등록된 품목이 없습니다
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 특이사항 */}
|
||||
<div className="mb-4">
|
||||
<p className="text-sm font-medium mb-2">【 특이사항 】</p>
|
||||
<div className="border border-gray-300 p-3 min-h-[50px] text-sm">
|
||||
{remarks || "-"}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 유의사항 */}
|
||||
<div className="mb-4">
|
||||
<p className="text-sm font-medium mb-2">【 유의사항 】</p>
|
||||
<div className="border border-gray-300 p-3 text-sm">
|
||||
<ul className="space-y-1">
|
||||
<li>• 발주 승인 완료 후 작업을 진행해주시기 바랍니다.</li>
|
||||
<li>• 납기 일정 부득이하게 변경이 필요할 경우 사전에 납품해주시기 바랍니다.</li>
|
||||
<li>• 기타 문의사항은 담당자에게 연락 부탁드립니다.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 문의 */}
|
||||
<div className="text-center text-sm text-gray-600">
|
||||
문의: 홍길동 | 010-1234-5678
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
202
src/components/orders/documents/TransactionDocument.tsx
Normal file
202
src/components/orders/documents/TransactionDocument.tsx
Normal file
@@ -0,0 +1,202 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 거래명세서 문서 컴포넌트
|
||||
* - 스크린샷 형식 + 지출결의서 디자인 스타일
|
||||
*/
|
||||
|
||||
import { formatAmount } from "@/utils/formatAmount";
|
||||
import { OrderItem } from "@/components/orders";
|
||||
|
||||
interface TransactionDocumentProps {
|
||||
orderNumber: string;
|
||||
orderDate: string;
|
||||
client: string;
|
||||
clientBusinessNumber?: string;
|
||||
clientCeo?: string;
|
||||
clientContact?: string;
|
||||
clientAddress?: string;
|
||||
clientSiteName?: string;
|
||||
companyName?: string;
|
||||
companyCeo?: string;
|
||||
companyBusinessNumber?: string;
|
||||
companyContact?: string;
|
||||
companyAddress?: string;
|
||||
items: OrderItem[];
|
||||
subtotal: number;
|
||||
discountRate: number;
|
||||
totalAmount: number;
|
||||
}
|
||||
|
||||
export function TransactionDocument({
|
||||
orderNumber,
|
||||
orderDate,
|
||||
client,
|
||||
clientBusinessNumber = "123-45-67890",
|
||||
clientCeo = "대표자",
|
||||
clientContact = "010-0123-4567",
|
||||
clientAddress = "서울시 강남구",
|
||||
clientSiteName = "-",
|
||||
companyName = "(주)케이디산업",
|
||||
companyCeo = "홍길동",
|
||||
companyBusinessNumber = "123-45-67890",
|
||||
companyContact = "02-1234-5678",
|
||||
companyAddress = "서울 강남구 테헤란로 123",
|
||||
items,
|
||||
subtotal,
|
||||
discountRate,
|
||||
totalAmount,
|
||||
}: TransactionDocumentProps) {
|
||||
const discountAmount = Math.round(subtotal * (discountRate / 100));
|
||||
const afterDiscount = subtotal - discountAmount;
|
||||
const vat = Math.round(afterDiscount * 0.1);
|
||||
const finalTotal = afterDiscount + vat;
|
||||
|
||||
return (
|
||||
<div className="bg-white p-8 min-h-full">
|
||||
{/* 제목 */}
|
||||
<div className="text-center mb-6">
|
||||
<h1 className="text-2xl font-bold tracking-widest mb-2">거 래 명 세 서</h1>
|
||||
<p className="text-sm text-gray-600">
|
||||
수주번호: {orderNumber} | 발행일: {orderDate}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 공급자/공급받는자 정보 */}
|
||||
<div className="grid grid-cols-2 gap-4 mb-4">
|
||||
<div className="border border-gray-300">
|
||||
<div className="bg-gray-800 text-white p-2 text-sm font-medium text-center">
|
||||
공급자
|
||||
</div>
|
||||
<div className="p-3 space-y-1 text-sm">
|
||||
<div className="flex">
|
||||
<span className="w-20 text-gray-600">상호</span>
|
||||
<span>{companyName}</span>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<span className="w-20 text-gray-600">대표자</span>
|
||||
<span>{companyCeo}</span>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<span className="w-20 text-gray-600">사업자번호</span>
|
||||
<span>{companyBusinessNumber}</span>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<span className="w-20 text-gray-600">주소</span>
|
||||
<span>{companyAddress}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border border-gray-300">
|
||||
<div className="bg-gray-800 text-white p-2 text-sm font-medium text-center">
|
||||
공급받는자
|
||||
</div>
|
||||
<div className="p-3 space-y-1 text-sm">
|
||||
<div className="flex">
|
||||
<span className="w-20 text-gray-600">상호</span>
|
||||
<span>{client}</span>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<span className="w-20 text-gray-600">담당자</span>
|
||||
<span>{clientCeo}</span>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<span className="w-20 text-gray-600">연락처</span>
|
||||
<span>{clientContact}</span>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<span className="w-20 text-gray-600">현장명</span>
|
||||
<span>{clientSiteName}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 품목내역 */}
|
||||
<div className="border border-gray-300 mb-4">
|
||||
<div className="bg-gray-800 text-white p-2 text-sm font-medium text-center">
|
||||
품목내역
|
||||
</div>
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="bg-gray-100 border-b border-gray-300">
|
||||
<th className="p-2 text-center font-medium border-r border-gray-300 w-12">순번</th>
|
||||
<th className="p-2 text-center font-medium border-r border-gray-300 w-20">품목코드</th>
|
||||
<th className="p-2 text-left font-medium border-r border-gray-300">품명</th>
|
||||
<th className="p-2 text-center font-medium border-r border-gray-300 w-24">규격</th>
|
||||
<th className="p-2 text-center font-medium border-r border-gray-300 w-12">수량</th>
|
||||
<th className="p-2 text-center font-medium border-r border-gray-300 w-12">단위</th>
|
||||
<th className="p-2 text-right font-medium border-r border-gray-300 w-24">단가</th>
|
||||
<th className="p-2 text-right font-medium w-24">공급가액</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.length > 0 ? (
|
||||
items.map((item, index) => (
|
||||
<tr key={item.id} className="border-b border-gray-300">
|
||||
<td className="p-2 text-center border-r border-gray-300">{index + 1}</td>
|
||||
<td className="p-2 text-center border-r border-gray-300">{item.itemCode}</td>
|
||||
<td className="p-2 border-r border-gray-300">{item.itemName}</td>
|
||||
<td className="p-2 text-center border-r border-gray-300">{item.spec}</td>
|
||||
<td className="p-2 text-center border-r border-gray-300">{item.quantity}</td>
|
||||
<td className="p-2 text-center border-r border-gray-300">{item.unit}</td>
|
||||
<td className="p-2 text-right border-r border-gray-300">{formatAmount(item.unitPrice)}</td>
|
||||
<td className="p-2 text-right">{formatAmount(item.amount)}</td>
|
||||
</tr>
|
||||
))
|
||||
) : (
|
||||
<tr className="border-b border-gray-300">
|
||||
<td colSpan={8} className="p-4 text-center text-gray-400">
|
||||
등록된 품목이 없습니다
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* 금액 계산 */}
|
||||
<div className="border border-gray-300 mb-6">
|
||||
<table className="w-full text-sm">
|
||||
<tbody>
|
||||
<tr className="border-b border-gray-300">
|
||||
<td className="p-2 bg-gray-100 border-r border-gray-300 w-32">공급가액</td>
|
||||
<td className="p-2 text-right">{formatAmount(subtotal)}원</td>
|
||||
</tr>
|
||||
<tr className="border-b border-gray-300">
|
||||
<td className="p-2 bg-gray-100 border-r border-gray-300">할인율</td>
|
||||
<td className="p-2 text-right">{discountRate}%</td>
|
||||
</tr>
|
||||
<tr className="border-b border-gray-300">
|
||||
<td className="p-2 bg-gray-100 border-r border-gray-300">할인액</td>
|
||||
<td className="p-2 text-right text-red-600">-{formatAmount(discountAmount)}원</td>
|
||||
</tr>
|
||||
<tr className="border-b border-gray-300">
|
||||
<td className="p-2 bg-gray-100 border-r border-gray-300">할인 후 공급가액</td>
|
||||
<td className="p-2 text-right">{formatAmount(afterDiscount)}원</td>
|
||||
</tr>
|
||||
<tr className="border-b border-gray-300">
|
||||
<td className="p-2 bg-gray-100 border-r border-gray-300">부가세 (10%)</td>
|
||||
<td className="p-2 text-right">{formatAmount(vat)}원</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="p-2 bg-gray-100 border-r border-gray-300 font-medium">합계 금액</td>
|
||||
<td className="p-2 text-right font-bold text-lg">₩ {formatAmount(finalTotal)}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* 증명 문구 */}
|
||||
<div className="text-center py-6 border-t border-gray-300">
|
||||
<p className="text-sm mb-4">위 금액을 거래하였음을 증명합니다.</p>
|
||||
<p className="text-sm text-gray-600 mb-4">{orderDate}</p>
|
||||
<div className="flex justify-center">
|
||||
<div className="w-12 h-12 border-2 border-red-400 rounded-full flex items-center justify-center text-red-400 text-xs">
|
||||
印
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
12
src/components/orders/documents/index.ts
Normal file
12
src/components/orders/documents/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* 문서 컴포넌트 exports
|
||||
*/
|
||||
|
||||
export { ContractDocument } from "./ContractDocument";
|
||||
export { TransactionDocument } from "./TransactionDocument";
|
||||
export { PurchaseOrderDocument } from "./PurchaseOrderDocument";
|
||||
export {
|
||||
OrderDocumentModal,
|
||||
type OrderDocumentType,
|
||||
type OrderDocumentData,
|
||||
} from "./OrderDocumentModal";
|
||||
17
src/components/orders/index.ts
Normal file
17
src/components/orders/index.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* 수주 관련 컴포넌트
|
||||
*/
|
||||
|
||||
export { OrderRegistration, type OrderFormData } from "./OrderRegistration";
|
||||
export { QuotationSelectDialog, type QuotationForSelect, type QuotationItem } from "./QuotationSelectDialog";
|
||||
export { ItemAddDialog, type OrderItem } from "./ItemAddDialog";
|
||||
|
||||
// 문서 컴포넌트
|
||||
export {
|
||||
ContractDocument,
|
||||
TransactionDocument,
|
||||
PurchaseOrderDocument,
|
||||
OrderDocumentModal,
|
||||
type OrderDocumentType,
|
||||
type OrderDocumentData,
|
||||
} from "./documents";
|
||||
314
src/components/outbound/ShipmentManagement/ShipmentCreate.tsx
Normal file
314
src/components/outbound/ShipmentManagement/ShipmentCreate.tsx
Normal file
@@ -0,0 +1,314 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 출하 등록 페이지
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { ArrowLeft, Truck } 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 { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { PageLayout } from '@/components/organisms/PageLayout';
|
||||
import {
|
||||
mockLotOptions,
|
||||
mockLogisticsOptions,
|
||||
mockVehicleTonnageOptions,
|
||||
priorityOptions,
|
||||
deliveryMethodOptions,
|
||||
} from './mockData';
|
||||
import type { ShipmentCreateFormData, ShipmentPriority, DeliveryMethod } from './types';
|
||||
|
||||
export function ShipmentCreate() {
|
||||
const router = useRouter();
|
||||
|
||||
// 폼 상태
|
||||
const [formData, setFormData] = useState<ShipmentCreateFormData>({
|
||||
lotNo: '',
|
||||
scheduledDate: new Date().toISOString().split('T')[0],
|
||||
priority: 'normal',
|
||||
deliveryMethod: 'pickup',
|
||||
logisticsCompany: '',
|
||||
vehicleTonnage: '',
|
||||
loadingTime: '',
|
||||
loadingManager: '',
|
||||
remarks: '',
|
||||
});
|
||||
|
||||
// validation 에러 상태
|
||||
const [validationErrors, setValidationErrors] = useState<string[]>([]);
|
||||
|
||||
// 폼 입력 핸들러
|
||||
const handleInputChange = (field: keyof ShipmentCreateFormData, value: string) => {
|
||||
setFormData(prev => ({ ...prev, [field]: value }));
|
||||
// 입력 시 에러 클리어
|
||||
if (validationErrors.length > 0) {
|
||||
setValidationErrors([]);
|
||||
}
|
||||
};
|
||||
|
||||
// 취소
|
||||
const handleCancel = () => {
|
||||
router.push('/ko/outbound/shipments');
|
||||
};
|
||||
|
||||
// validation 체크
|
||||
const validateForm = (): boolean => {
|
||||
const errors: string[] = [];
|
||||
|
||||
// 필수 필드 체크
|
||||
if (!formData.lotNo) {
|
||||
errors.push('로트번호는 필수 선택 항목입니다.');
|
||||
}
|
||||
if (!formData.scheduledDate) {
|
||||
errors.push('출고예정일은 필수 입력 항목입니다.');
|
||||
}
|
||||
if (!formData.priority) {
|
||||
errors.push('출고 우선순위는 필수 선택 항목입니다.');
|
||||
}
|
||||
if (!formData.deliveryMethod) {
|
||||
errors.push('배송방식은 필수 선택 항목입니다.');
|
||||
}
|
||||
|
||||
setValidationErrors(errors);
|
||||
return errors.length === 0;
|
||||
};
|
||||
|
||||
// 저장
|
||||
const handleSubmit = () => {
|
||||
// validation 체크
|
||||
if (!validateForm()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// TODO: API 호출
|
||||
console.log('Submit:', formData);
|
||||
router.push('/ko/outbound/shipments');
|
||||
};
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
<div className="space-y-6">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleCancel}
|
||||
>
|
||||
<ArrowLeft className="w-5 h-5" />
|
||||
</Button>
|
||||
<Truck className="w-6 h-6" />
|
||||
<h1 className="text-xl font-semibold">출하 등록</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" onClick={handleCancel}>
|
||||
취소
|
||||
</Button>
|
||||
<Button onClick={handleSubmit}>
|
||||
저장
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Validation 에러 표시 */}
|
||||
{validationErrors.length > 0 && (
|
||||
<Alert className="bg-red-50 border-red-200">
|
||||
<AlertDescription className="text-red-900">
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="text-lg">⚠️</span>
|
||||
<div className="flex-1">
|
||||
<strong className="block mb-2">
|
||||
입력 내용을 확인해주세요 ({validationErrors.length}개 오류)
|
||||
</strong>
|
||||
<ul className="space-y-1 text-sm">
|
||||
{validationErrors.map((error, index) => (
|
||||
<li key={index} className="flex items-start gap-1">
|
||||
<span>•</span>
|
||||
<span>{error}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* 수주 선택 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">수주 선택</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
<Label>로트번호 *</Label>
|
||||
<Select
|
||||
value={formData.lotNo}
|
||||
onValueChange={(value) => handleInputChange('lotNo', value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="로트 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{mockLotOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label} ({option.customerName} - {option.siteName})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 출고 정보 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">출고 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>출고예정일 *</Label>
|
||||
<Input
|
||||
type="date"
|
||||
value={formData.scheduledDate}
|
||||
onChange={(e) => handleInputChange('scheduledDate', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>출고 우선순위 *</Label>
|
||||
<Select
|
||||
value={formData.priority}
|
||||
onValueChange={(value) => handleInputChange('priority', value as ShipmentPriority)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="우선순위 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{priorityOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>배송방식 *</Label>
|
||||
<Select
|
||||
value={formData.deliveryMethod}
|
||||
onValueChange={(value) => handleInputChange('deliveryMethod', value as DeliveryMethod)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="배송방식 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{deliveryMethodOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 상차 (물류업체) - 배송방식이 상차 또는 물류사일 때 표시 */}
|
||||
{(formData.deliveryMethod === 'pickup' || formData.deliveryMethod === 'logistics') && (
|
||||
<Card className="bg-muted/30">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium">상차 (물류업체)</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>물류사</Label>
|
||||
<Select
|
||||
value={formData.logisticsCompany || ''}
|
||||
onValueChange={(value) => handleInputChange('logisticsCompany', value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{mockLogisticsOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>차량 톤수 (물량)</Label>
|
||||
<Select
|
||||
value={formData.vehicleTonnage || ''}
|
||||
onValueChange={(value) => handleInputChange('vehicleTonnage', value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{mockVehicleTonnageOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>상차시간 (입차예정)</Label>
|
||||
<Input
|
||||
type="datetime-local"
|
||||
value={formData.loadingTime || ''}
|
||||
onChange={(e) => handleInputChange('loadingTime', e.target.value)}
|
||||
placeholder="연도. 월. 일. -- --:--"
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
물류업체와 입차시간 확정 후 입력하세요.
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>상차담당자</Label>
|
||||
<Input
|
||||
value={formData.loadingManager || ''}
|
||||
onChange={(e) => handleInputChange('loadingManager', e.target.value)}
|
||||
placeholder="상차 작업 담당자명"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>비고</Label>
|
||||
<Textarea
|
||||
value={formData.remarks || ''}
|
||||
onChange={(e) => handleInputChange('remarks', e.target.value)}
|
||||
placeholder="특이사항 입력"
|
||||
rows={4}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
351
src/components/outbound/ShipmentManagement/ShipmentDetail.tsx
Normal file
351
src/components/outbound/ShipmentManagement/ShipmentDetail.tsx
Normal file
@@ -0,0 +1,351 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 출하 상세 페이지
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Truck, FileText, Receipt, ClipboardList, Check, Printer, X } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { VisuallyHidden } from '@radix-ui/react-visually-hidden';
|
||||
import { PageLayout } from '@/components/organisms/PageLayout';
|
||||
import { mockShipmentDetail } from './mockData';
|
||||
import {
|
||||
SHIPMENT_STATUS_LABELS,
|
||||
SHIPMENT_STATUS_STYLES,
|
||||
PRIORITY_LABELS,
|
||||
PRIORITY_STYLES,
|
||||
DELIVERY_METHOD_LABELS,
|
||||
} from './types';
|
||||
import { ShippingSlip } from './documents/ShippingSlip';
|
||||
import { TransactionStatement } from './documents/TransactionStatement';
|
||||
import { DeliveryConfirmation } from './documents/DeliveryConfirmation';
|
||||
|
||||
interface ShipmentDetailProps {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export function ShipmentDetail({ id }: ShipmentDetailProps) {
|
||||
const router = useRouter();
|
||||
const [previewDocument, setPreviewDocument] = useState<'shipping' | 'transaction' | 'delivery' | null>(null);
|
||||
|
||||
// Mock 데이터 사용 (실제로는 API에서 id로 조회)
|
||||
const data = mockShipmentDetail;
|
||||
|
||||
// 목록으로 이동
|
||||
const handleGoBack = () => {
|
||||
router.push('/ko/outbound/shipments');
|
||||
};
|
||||
|
||||
// 수정 페이지로 이동
|
||||
const handleEdit = () => {
|
||||
router.push(`/ko/outbound/shipments/${id}/edit`);
|
||||
};
|
||||
|
||||
// 인쇄
|
||||
const handlePrint = () => {
|
||||
window.print();
|
||||
};
|
||||
|
||||
// 정보 영역 렌더링
|
||||
const renderInfoField = (label: string, value: React.ReactNode, className?: string) => (
|
||||
<div className={className}>
|
||||
<div className="text-sm text-muted-foreground mb-1">{label}</div>
|
||||
<div className="font-medium">{value}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
<div className="space-y-6">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Truck className="w-6 h-6" />
|
||||
<h1 className="text-xl font-semibold">출하 상세</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* 문서 미리보기 버튼 */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPreviewDocument('shipping')}
|
||||
>
|
||||
<FileText className="w-4 h-4 mr-1" />
|
||||
출고증
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPreviewDocument('transaction')}
|
||||
>
|
||||
<Receipt className="w-4 h-4 mr-1" />
|
||||
거래명세서
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPreviewDocument('delivery')}
|
||||
>
|
||||
<ClipboardList className="w-4 h-4 mr-1" />
|
||||
납품확인서
|
||||
</Button>
|
||||
<div className="w-px h-6 bg-border mx-2" />
|
||||
<Button variant="outline" onClick={handleGoBack}>
|
||||
목록
|
||||
</Button>
|
||||
<Button onClick={handleEdit}>
|
||||
수정
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 메인 콘텐츠 */}
|
||||
<div className="space-y-6">
|
||||
{/* 출고 정보 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">출고 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-6">
|
||||
{renderInfoField('출고번호', data.shipmentNo)}
|
||||
{renderInfoField('출고예정일', data.scheduledDate)}
|
||||
{renderInfoField('로트번호', data.lotNo)}
|
||||
{renderInfoField(
|
||||
'출고상태',
|
||||
<Badge className={SHIPMENT_STATUS_STYLES[data.status]}>
|
||||
{SHIPMENT_STATUS_LABELS[data.status]}
|
||||
</Badge>
|
||||
)}
|
||||
{renderInfoField(
|
||||
'출고 우선순위',
|
||||
<Badge className={PRIORITY_STYLES[data.priority]}>
|
||||
{PRIORITY_LABELS[data.priority]}
|
||||
</Badge>
|
||||
)}
|
||||
{renderInfoField(
|
||||
'배송방식',
|
||||
<Badge variant="outline">
|
||||
{DELIVERY_METHOD_LABELS[data.deliveryMethod]}
|
||||
</Badge>
|
||||
)}
|
||||
{renderInfoField(
|
||||
'입금확인',
|
||||
data.depositConfirmed ? (
|
||||
<span className="flex items-center gap-1 text-green-600">
|
||||
<Check className="w-4 h-4" />
|
||||
확인됨
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">미확인</span>
|
||||
)
|
||||
)}
|
||||
{renderInfoField(
|
||||
'세금계산서',
|
||||
data.invoiceIssued ? (
|
||||
<span className="flex items-center gap-1 text-green-600">
|
||||
<Check className="w-4 h-4" />
|
||||
발행됨
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">미발행</span>
|
||||
)
|
||||
)}
|
||||
{renderInfoField('거래처 등급', data.customerGrade)}
|
||||
{renderInfoField(
|
||||
'출하가능',
|
||||
data.canShip ? (
|
||||
<span className="flex items-center gap-1 text-green-600">
|
||||
<Check className="w-4 h-4" />
|
||||
가능
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-1 text-red-600">
|
||||
<X className="w-4 h-4" />
|
||||
불가
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
{renderInfoField('상차담당자', data.loadingManager || '-')}
|
||||
{renderInfoField(
|
||||
'상차완료',
|
||||
data.loadingCompleted ? (
|
||||
<span className="flex items-center gap-1 text-green-600">
|
||||
<Check className="w-4 h-4" />
|
||||
완료 ({data.loadingCompleted})
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">-</span>
|
||||
)
|
||||
)}
|
||||
{renderInfoField('등록자', data.registrant || '-')}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 발주처/배송 정보 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">발주처/배송 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-6">
|
||||
{renderInfoField('발주처', data.customerName)}
|
||||
{renderInfoField('현장명', data.siteName)}
|
||||
{renderInfoField('배송주소', data.deliveryAddress, 'md:col-span-2')}
|
||||
{renderInfoField('인수자', data.receiver || '-')}
|
||||
{renderInfoField('연락처', data.receiverContact || '-')}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 출고 품목 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">출고 품목</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-12 text-center">No</TableHead>
|
||||
<TableHead className="w-24">품목코드</TableHead>
|
||||
<TableHead>품목명</TableHead>
|
||||
<TableHead className="w-24">층/M호</TableHead>
|
||||
<TableHead className="w-28">규격</TableHead>
|
||||
<TableHead className="w-16 text-center">수량</TableHead>
|
||||
<TableHead className="w-36">LOT번호</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data.products.map((product) => (
|
||||
<TableRow key={product.id}>
|
||||
<TableCell className="text-center">{product.no}</TableCell>
|
||||
<TableCell>{product.itemCode}</TableCell>
|
||||
<TableCell>{product.itemName}</TableCell>
|
||||
<TableCell>{product.floorUnit}</TableCell>
|
||||
<TableCell>{product.specification}</TableCell>
|
||||
<TableCell className="text-center">{product.quantity}</TableCell>
|
||||
<TableCell>{product.lotNo}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 배차 정보 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">배차 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-6">
|
||||
{renderInfoField('배송방식', DELIVERY_METHOD_LABELS[data.deliveryMethod])}
|
||||
{renderInfoField('물류사', data.logisticsCompany || '-')}
|
||||
{renderInfoField('차량 톤수', data.vehicleTonnage || '-')}
|
||||
{renderInfoField('운송비', data.shippingCost !== undefined ? `${data.shippingCost.toLocaleString()}원` : '-')}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 차량/운전자 정보 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">차량/운전자 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-6">
|
||||
{renderInfoField('차량번호', data.vehicleNo || '-')}
|
||||
{renderInfoField('운전자', data.driverName || '-')}
|
||||
{renderInfoField('운전자 연락처', data.driverContact || '-')}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 비고 */}
|
||||
{data.remarks && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">비고</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm whitespace-pre-wrap">{data.remarks}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
{/* 문서 미리보기 다이얼로그 - 작업일지 모달 패턴 적용 */}
|
||||
<Dialog open={previewDocument !== null} onOpenChange={() => setPreviewDocument(null)}>
|
||||
<DialogContent className="sm:max-w-5xl max-h-[90vh] overflow-y-auto p-0 gap-0 bg-gray-100">
|
||||
{/* 접근성을 위한 숨겨진 타이틀 */}
|
||||
<VisuallyHidden>
|
||||
<DialogTitle>
|
||||
{previewDocument === 'shipping' && '출고증'}
|
||||
{previewDocument === 'transaction' && '거래명세서'}
|
||||
{previewDocument === 'delivery' && '납품확인서'}
|
||||
</DialogTitle>
|
||||
</VisuallyHidden>
|
||||
|
||||
{/* 모달 헤더 - 작업일지 스타일 */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b bg-white sticky top-0 z-10">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="font-semibold text-lg">
|
||||
{previewDocument === 'shipping' && '출고증 미리보기'}
|
||||
{previewDocument === 'transaction' && '거래명세서 미리보기'}
|
||||
{previewDocument === 'delivery' && '납품확인서'}
|
||||
</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{data.customerName}
|
||||
</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
({data.shipmentNo})
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={handlePrint}>
|
||||
<Printer className="w-4 h-4 mr-1.5" />
|
||||
인쇄
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => setPreviewDocument(null)}
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 문서 본문 - 흰색 카드 형태 */}
|
||||
<div className="m-6 p-6 bg-white rounded-lg shadow-sm">
|
||||
{previewDocument === 'shipping' && <ShippingSlip data={data} />}
|
||||
{previewDocument === 'transaction' && <TransactionStatement data={data} />}
|
||||
{previewDocument === 'delivery' && <DeliveryConfirmation data={data} />}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
393
src/components/outbound/ShipmentManagement/ShipmentEdit.tsx
Normal file
393
src/components/outbound/ShipmentManagement/ShipmentEdit.tsx
Normal file
@@ -0,0 +1,393 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 출하 수정 페이지
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { ArrowLeft, Truck } 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 { Badge } from '@/components/ui/badge';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { PageLayout } from '@/components/organisms/PageLayout';
|
||||
import {
|
||||
mockShipmentDetail,
|
||||
mockLogisticsOptions,
|
||||
mockVehicleTonnageOptions,
|
||||
priorityOptions,
|
||||
deliveryMethodOptions,
|
||||
} from './mockData';
|
||||
import type { ShipmentEditFormData, ShipmentPriority, DeliveryMethod } from './types';
|
||||
|
||||
interface ShipmentEditProps {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export function ShipmentEdit({ id }: ShipmentEditProps) {
|
||||
const router = useRouter();
|
||||
|
||||
// Mock 데이터에서 초기값 설정 (실제로는 API에서 조회)
|
||||
const detail = mockShipmentDetail;
|
||||
|
||||
// 폼 상태
|
||||
const [formData, setFormData] = useState<ShipmentEditFormData>({
|
||||
scheduledDate: detail.scheduledDate,
|
||||
priority: detail.priority,
|
||||
deliveryMethod: detail.deliveryMethod,
|
||||
loadingManager: detail.loadingManager || '',
|
||||
logisticsCompany: detail.logisticsCompany || '',
|
||||
vehicleTonnage: detail.vehicleTonnage || '',
|
||||
vehicleNo: detail.vehicleNo || '',
|
||||
shippingCost: detail.shippingCost,
|
||||
driverName: detail.driverName || '',
|
||||
driverContact: detail.driverContact || '',
|
||||
expectedArrival: '',
|
||||
confirmedArrival: '',
|
||||
changeReason: '',
|
||||
remarks: detail.remarks || '',
|
||||
});
|
||||
|
||||
// validation 에러 상태
|
||||
const [validationErrors, setValidationErrors] = useState<string[]>([]);
|
||||
|
||||
// 폼 입력 핸들러
|
||||
const handleInputChange = (field: keyof ShipmentEditFormData, value: string | number | undefined) => {
|
||||
setFormData(prev => ({ ...prev, [field]: value }));
|
||||
// 입력 시 에러 클리어
|
||||
if (validationErrors.length > 0) {
|
||||
setValidationErrors([]);
|
||||
}
|
||||
};
|
||||
|
||||
// 취소
|
||||
const handleCancel = () => {
|
||||
router.push(`/ko/outbound/shipments/${id}`);
|
||||
};
|
||||
|
||||
// validation 체크
|
||||
const validateForm = (): boolean => {
|
||||
const errors: string[] = [];
|
||||
|
||||
// 필수 필드 체크
|
||||
if (!formData.scheduledDate) {
|
||||
errors.push('출고예정일은 필수 입력 항목입니다.');
|
||||
}
|
||||
if (!formData.priority) {
|
||||
errors.push('출고 우선순위는 필수 선택 항목입니다.');
|
||||
}
|
||||
if (!formData.deliveryMethod) {
|
||||
errors.push('배송방식은 필수 선택 항목입니다.');
|
||||
}
|
||||
if (!formData.changeReason.trim()) {
|
||||
errors.push('변경 사유는 필수 입력 항목입니다.');
|
||||
}
|
||||
|
||||
setValidationErrors(errors);
|
||||
return errors.length === 0;
|
||||
};
|
||||
|
||||
// 저장
|
||||
const handleSubmit = () => {
|
||||
// validation 체크
|
||||
if (!validateForm()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// TODO: API 호출
|
||||
console.log('Submit:', formData);
|
||||
router.push(`/ko/outbound/shipments/${id}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
<div className="space-y-6">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleCancel}
|
||||
>
|
||||
<ArrowLeft className="w-5 h-5" />
|
||||
</Button>
|
||||
<Truck className="w-6 h-6" />
|
||||
<h1 className="text-xl font-semibold">출고 수정</h1>
|
||||
<span className="text-sm text-muted-foreground">{detail.lotNo}</span>
|
||||
<Badge variant="outline" className="bg-yellow-100 text-yellow-800">
|
||||
출하보류
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" onClick={handleCancel}>
|
||||
취소
|
||||
</Button>
|
||||
<Button onClick={handleSubmit}>
|
||||
저장
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Validation 에러 표시 */}
|
||||
{validationErrors.length > 0 && (
|
||||
<Alert className="bg-red-50 border-red-200">
|
||||
<AlertDescription className="text-red-900">
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="text-lg">⚠️</span>
|
||||
<div className="flex-1">
|
||||
<strong className="block mb-2">
|
||||
입력 내용을 확인해주세요 ({validationErrors.length}개 오류)
|
||||
</strong>
|
||||
<ul className="space-y-1 text-sm">
|
||||
{validationErrors.map((error, index) => (
|
||||
<li key={index} className="flex items-start gap-1">
|
||||
<span>•</span>
|
||||
<span>{error}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* 기본 정보 (읽기 전용) */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">기본 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-6">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-muted-foreground">출고번호</Label>
|
||||
<div className="font-medium">{detail.shipmentNo}</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-muted-foreground">로트번호</Label>
|
||||
<div className="font-medium">{detail.lotNo}</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-muted-foreground">발주처</Label>
|
||||
<div className="font-medium">{detail.customerName}</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-muted-foreground">현장명</Label>
|
||||
<div className="font-medium">{detail.siteName}</div>
|
||||
</div>
|
||||
<div className="space-y-1 md:col-span-4">
|
||||
<Label className="text-muted-foreground">배송주소</Label>
|
||||
<div className="font-medium">{detail.deliveryAddress}</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 출고 정보 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">출고 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>출고예정일 *</Label>
|
||||
<Input
|
||||
type="date"
|
||||
value={formData.scheduledDate}
|
||||
onChange={(e) => handleInputChange('scheduledDate', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>출고 우선순위 *</Label>
|
||||
<Select
|
||||
value={formData.priority}
|
||||
onValueChange={(value) => handleInputChange('priority', value as ShipmentPriority)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="우선순위 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{priorityOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>배송방식 *</Label>
|
||||
<Select
|
||||
value={formData.deliveryMethod}
|
||||
onValueChange={(value) => handleInputChange('deliveryMethod', value as DeliveryMethod)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="배송방식 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{deliveryMethodOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>상차담당자</Label>
|
||||
<Input
|
||||
value={formData.loadingManager || ''}
|
||||
onChange={(e) => handleInputChange('loadingManager', 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>
|
||||
<Select
|
||||
value={formData.logisticsCompany || ''}
|
||||
onValueChange={(value) => handleInputChange('logisticsCompany', value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{mockLogisticsOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>차량 톤수</Label>
|
||||
<Select
|
||||
value={formData.vehicleTonnage || ''}
|
||||
onValueChange={(value) => handleInputChange('vehicleTonnage', value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{mockVehicleTonnageOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 배차 정보 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">배차 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>차량번호</Label>
|
||||
<Input
|
||||
value={formData.vehicleNo || ''}
|
||||
onChange={(e) => handleInputChange('vehicleNo', e.target.value)}
|
||||
placeholder="예: 12가 3456"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>운송비</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={formData.shippingCost || ''}
|
||||
onChange={(e) => handleInputChange('shippingCost', parseInt(e.target.value) || undefined)}
|
||||
placeholder="0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>운전자명</Label>
|
||||
<Input
|
||||
value={formData.driverName || ''}
|
||||
onChange={(e) => handleInputChange('driverName', e.target.value)}
|
||||
placeholder="운전자명"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>운전자 연락처</Label>
|
||||
<Input
|
||||
value={formData.driverContact || ''}
|
||||
onChange={(e) => handleInputChange('driverContact', e.target.value)}
|
||||
placeholder="010-0000-0000"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>입차예정시간</Label>
|
||||
<Input
|
||||
type="datetime-local"
|
||||
value={formData.expectedArrival || ''}
|
||||
onChange={(e) => handleInputChange('expectedArrival', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>입차확정시간</Label>
|
||||
<Input
|
||||
type="datetime-local"
|
||||
value={formData.confirmedArrival || ''}
|
||||
onChange={(e) => handleInputChange('confirmedArrival', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 변경 사유 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">변경 사유</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>변경 사유 *</Label>
|
||||
<Input
|
||||
value={formData.changeReason}
|
||||
onChange={(e) => handleInputChange('changeReason', e.target.value)}
|
||||
placeholder="변경 사유를 입력하세요 (예: 고객 요청, 물류사 일정 조율 등)"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>비고</Label>
|
||||
<Textarea
|
||||
value={formData.remarks || ''}
|
||||
onChange={(e) => handleInputChange('remarks', e.target.value)}
|
||||
placeholder="특이사항 입력"
|
||||
rows={4}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
339
src/components/outbound/ShipmentManagement/ShipmentList.tsx
Normal file
339
src/components/outbound/ShipmentManagement/ShipmentList.tsx
Normal file
@@ -0,0 +1,339 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 출하 목록 페이지
|
||||
* IntegratedListTemplateV2 패턴 적용
|
||||
*/
|
||||
|
||||
import { useState, useMemo, useCallback } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import {
|
||||
Truck,
|
||||
Package,
|
||||
Clock,
|
||||
AlertTriangle,
|
||||
Eye,
|
||||
Plus,
|
||||
Check,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { TableCell, TableRow } from '@/components/ui/table';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import {
|
||||
IntegratedListTemplateV2,
|
||||
TabOption,
|
||||
TableColumn,
|
||||
StatCard,
|
||||
} from '@/components/templates/IntegratedListTemplateV2';
|
||||
import { ListMobileCard, InfoField } from '@/components/organisms/ListMobileCard';
|
||||
import { mockShipmentItems, mockStats, mockFilterTabs } from './mockData';
|
||||
import {
|
||||
SHIPMENT_STATUS_LABELS,
|
||||
SHIPMENT_STATUS_STYLES,
|
||||
PRIORITY_LABELS,
|
||||
PRIORITY_STYLES,
|
||||
DELIVERY_METHOD_LABELS,
|
||||
} from './types';
|
||||
import type { ShipmentItem, ShipmentStatus } from './types';
|
||||
|
||||
// 페이지당 항목 수
|
||||
const ITEMS_PER_PAGE = 20;
|
||||
|
||||
export function ShipmentList() {
|
||||
const router = useRouter();
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set());
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [activeFilter, setActiveFilter] = useState<string>('all');
|
||||
|
||||
// 출고 불가 건수 계산
|
||||
const cannotShipCount = useMemo(() => {
|
||||
return mockShipmentItems.filter(item => !item.canShip).length;
|
||||
}, []);
|
||||
|
||||
// 통계 카드
|
||||
const stats: StatCard[] = [
|
||||
{
|
||||
label: '당일 출하',
|
||||
value: `${mockStats.todayShipmentCount}건`,
|
||||
icon: Package,
|
||||
iconColor: 'text-green-600',
|
||||
},
|
||||
{
|
||||
label: '출고 대기',
|
||||
value: `${mockStats.scheduledCount}건`,
|
||||
icon: Clock,
|
||||
iconColor: 'text-yellow-600',
|
||||
},
|
||||
{
|
||||
label: '배송중',
|
||||
value: `${mockStats.shippingCount}건`,
|
||||
icon: Truck,
|
||||
iconColor: 'text-blue-600',
|
||||
},
|
||||
{
|
||||
label: '긴급 출하',
|
||||
value: `${mockStats.urgentCount}건`,
|
||||
icon: AlertTriangle,
|
||||
iconColor: 'text-red-600',
|
||||
},
|
||||
];
|
||||
|
||||
// 테이블 컬럼
|
||||
const tableColumns: TableColumn[] = [
|
||||
{ key: 'no', label: '번호', className: 'w-[60px] text-center' },
|
||||
{ key: 'shipmentNo', label: '출고번호', className: 'min-w-[130px]' },
|
||||
{ key: 'lotNo', label: '로트번호', className: 'min-w-[150px]' },
|
||||
{ key: 'scheduledDate', label: '출고예정일', className: 'w-[120px] text-center' },
|
||||
{ key: 'status', label: '상태', className: 'w-[100px] text-center' },
|
||||
{ key: 'canShip', label: '출하가능', className: 'w-[80px] text-center' },
|
||||
{ key: 'deliveryMethod', label: '배송', className: 'w-[100px] text-center' },
|
||||
{ key: 'customerName', label: '발주처', className: 'min-w-[120px]' },
|
||||
{ key: 'siteName', label: '현장명', className: 'min-w-[120px]' },
|
||||
{ key: 'manager', label: '담당', className: 'w-[80px] text-center' },
|
||||
{ key: 'deliveryTime', label: '납기(수배시간)', className: 'w-[120px] text-center' },
|
||||
];
|
||||
|
||||
// 필터링된 데이터
|
||||
const filteredResults = useMemo(() => {
|
||||
let result = [...mockShipmentItems];
|
||||
|
||||
// 탭 필터
|
||||
if (activeFilter !== 'all' && activeFilter !== 'calendar') {
|
||||
result = result.filter((item) => item.status === activeFilter);
|
||||
}
|
||||
|
||||
// 검색 필터
|
||||
if (searchTerm) {
|
||||
const term = searchTerm.toLowerCase();
|
||||
result = result.filter(
|
||||
(item) =>
|
||||
item.shipmentNo.toLowerCase().includes(term) ||
|
||||
item.lotNo.toLowerCase().includes(term) ||
|
||||
item.customerName.toLowerCase().includes(term) ||
|
||||
item.siteName.toLowerCase().includes(term)
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [searchTerm, activeFilter]);
|
||||
|
||||
// 페이지네이션 데이터
|
||||
const paginatedData = useMemo(() => {
|
||||
const startIndex = (currentPage - 1) * ITEMS_PER_PAGE;
|
||||
return filteredResults.slice(startIndex, startIndex + ITEMS_PER_PAGE);
|
||||
}, [filteredResults, currentPage]);
|
||||
|
||||
// 페이지네이션 설정
|
||||
const pagination = {
|
||||
currentPage,
|
||||
totalPages: Math.ceil(filteredResults.length / ITEMS_PER_PAGE),
|
||||
totalItems: filteredResults.length,
|
||||
itemsPerPage: ITEMS_PER_PAGE,
|
||||
onPageChange: setCurrentPage,
|
||||
};
|
||||
|
||||
// 선택 핸들러
|
||||
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((item) => item.id)));
|
||||
}
|
||||
}, [paginatedData, selectedItems.size]);
|
||||
|
||||
// 검색 변경 시 페이지 리셋
|
||||
const handleSearchChange = (value: string) => {
|
||||
setSearchTerm(value);
|
||||
setCurrentPage(1);
|
||||
};
|
||||
|
||||
// 탭 변경 시 페이지 리셋
|
||||
const handleTabChange = (value: string) => {
|
||||
setActiveFilter(value);
|
||||
setCurrentPage(1);
|
||||
setSelectedItems(new Set());
|
||||
};
|
||||
|
||||
// 상세 보기
|
||||
const handleView = useCallback(
|
||||
(id: string) => {
|
||||
router.push(`/ko/outbound/shipments/${id}`);
|
||||
},
|
||||
[router]
|
||||
);
|
||||
|
||||
// 등록 페이지로 이동
|
||||
const handleCreate = () => {
|
||||
router.push('/ko/outbound/shipments/new');
|
||||
};
|
||||
|
||||
// 테이블 행 렌더링
|
||||
const renderTableRow = (item: ShipmentItem, index: number, globalIndex: number) => {
|
||||
const isSelected = selectedItems.has(item.id);
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
key={item.id}
|
||||
className={`cursor-pointer hover:bg-muted/50 ${isSelected ? 'bg-blue-50' : ''}`}
|
||||
onClick={() => handleView(item.id)}
|
||||
>
|
||||
<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 className="font-medium">{item.shipmentNo}</TableCell>
|
||||
<TableCell>{item.lotNo}</TableCell>
|
||||
<TableCell className="text-center">{item.scheduledDate}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Badge className={`text-xs ${SHIPMENT_STATUS_STYLES[item.status]}`}>
|
||||
{SHIPMENT_STATUS_LABELS[item.status]}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{item.canShip ? (
|
||||
<Check className="w-4 h-4 mx-auto text-green-600" />
|
||||
) : (
|
||||
<X className="w-4 h-4 mx-auto text-red-600" />
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-center">{DELIVERY_METHOD_LABELS[item.deliveryMethod]}</TableCell>
|
||||
<TableCell>{item.customerName}</TableCell>
|
||||
<TableCell className="max-w-[120px] truncate">{item.siteName}</TableCell>
|
||||
<TableCell className="text-center">{item.manager || '-'}</TableCell>
|
||||
<TableCell className="text-center">{item.deliveryTime || '-'}</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
};
|
||||
|
||||
// 모바일 카드 렌더링
|
||||
const renderMobileCard = (
|
||||
item: ShipmentItem,
|
||||
index: number,
|
||||
globalIndex: number,
|
||||
isSelected: boolean,
|
||||
onToggle: () => void
|
||||
) => {
|
||||
return (
|
||||
<ListMobileCard
|
||||
id={item.id}
|
||||
isSelected={isSelected}
|
||||
onToggleSelection={onToggle}
|
||||
onClick={() => handleView(item.id)}
|
||||
headerBadges={
|
||||
<>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
#{globalIndex}
|
||||
</Badge>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{item.shipmentNo}
|
||||
</Badge>
|
||||
</>
|
||||
}
|
||||
title={item.siteName}
|
||||
statusBadge={
|
||||
<Badge className={`text-xs ${SHIPMENT_STATUS_STYLES[item.status]}`}>
|
||||
{SHIPMENT_STATUS_LABELS[item.status]}
|
||||
</Badge>
|
||||
}
|
||||
infoGrid={
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-3">
|
||||
<InfoField label="로트번호" value={item.lotNo} />
|
||||
<InfoField label="발주처" value={item.customerName} />
|
||||
<InfoField label="출고예정일" value={item.scheduledDate} />
|
||||
<InfoField label="배송방식" value={DELIVERY_METHOD_LABELS[item.deliveryMethod]} />
|
||||
<InfoField label="출하가능" value={item.canShip ? '가능' : '불가'} />
|
||||
</div>
|
||||
}
|
||||
actions={
|
||||
isSelected && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="flex-1"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleView(item.id);
|
||||
}}
|
||||
>
|
||||
<Eye className="w-4 h-4 mr-1" />
|
||||
상세
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
// 탭 옵션
|
||||
const tabs: TabOption[] = mockFilterTabs.map((tab) => ({
|
||||
value: tab.key,
|
||||
label: tab.label,
|
||||
count: tab.count,
|
||||
}));
|
||||
|
||||
// 헤더 액션
|
||||
const headerActions = (
|
||||
<Button onClick={handleCreate}>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
출하 등록
|
||||
</Button>
|
||||
);
|
||||
|
||||
// 카드와 검색 영역 사이에 표시할 경고 알림
|
||||
const alertBanner = cannotShipCount > 0 && (
|
||||
<Alert className="mb-4 bg-orange-50 border-orange-200">
|
||||
<AlertTriangle className="h-4 w-4 text-orange-600" />
|
||||
<AlertDescription className="text-orange-800">
|
||||
출고불가 {cannotShipCount}건 - 입금확인 및 세금계산서 발행 완료 후 출고 진행이 가능합니다.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
|
||||
return (
|
||||
<IntegratedListTemplateV2<ShipmentItem>
|
||||
title="출하 목록"
|
||||
icon={Truck}
|
||||
stats={stats}
|
||||
searchValue={searchTerm}
|
||||
onSearchChange={handleSearchChange}
|
||||
searchPlaceholder="출고번호, 로트번호, 발주처, 현장명 검색..."
|
||||
tabs={tabs}
|
||||
activeTab={activeFilter}
|
||||
onTabChange={handleTabChange}
|
||||
tableColumns={tableColumns}
|
||||
data={paginatedData}
|
||||
totalCount={filteredResults.length}
|
||||
allData={filteredResults}
|
||||
selectedItems={selectedItems}
|
||||
onToggleSelection={handleToggleSelection}
|
||||
onToggleSelectAll={handleToggleSelectAll}
|
||||
getItemId={(item) => item.id}
|
||||
renderTableRow={renderTableRow}
|
||||
renderMobileCard={renderMobileCard}
|
||||
pagination={pagination}
|
||||
headerActions={headerActions}
|
||||
alertBanner={alertBanner}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 납품확인서 미리보기/인쇄 문서
|
||||
*/
|
||||
|
||||
import type { ShipmentDetail } from '../types';
|
||||
|
||||
interface DeliveryConfirmationProps {
|
||||
data: ShipmentDetail;
|
||||
}
|
||||
|
||||
export function DeliveryConfirmation({ data }: DeliveryConfirmationProps) {
|
||||
return (
|
||||
<div className="bg-white p-8 max-w-3xl mx-auto text-sm print:p-0 print:max-w-none">
|
||||
{/* 헤더 */}
|
||||
<div className="flex justify-between items-start mb-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="text-2xl font-bold">KD</div>
|
||||
<div>
|
||||
<div className="text-xs">경동기업</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-2xl font-bold tracking-[1rem]">납 품 확 인 서</div>
|
||||
<table className="text-xs border-collapse">
|
||||
<tbody>
|
||||
{/* 헤더: 결재 + 작성/검토/승인 */}
|
||||
<tr>
|
||||
<td className="border px-2 py-1 bg-muted" rowSpan={3}>
|
||||
<div className="flex flex-col items-center justify-center h-full">
|
||||
<span>결</span>
|
||||
<span>재</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="border px-2 py-1 bg-muted text-center w-16">작성</td>
|
||||
<td className="border px-2 py-1 bg-muted text-center w-16">검토</td>
|
||||
<td className="border px-2 py-1 bg-muted text-center w-16">승인</td>
|
||||
</tr>
|
||||
{/* 내용: 서명란 */}
|
||||
<tr>
|
||||
<td className="border px-2 py-1 text-center h-10"></td>
|
||||
<td className="border px-2 py-1 text-center h-10"></td>
|
||||
<td className="border px-2 py-1 text-center h-10"></td>
|
||||
</tr>
|
||||
{/* 부서 */}
|
||||
<tr>
|
||||
<td className="border px-2 py-1 text-center bg-muted/50">판매/<br/>전산</td>
|
||||
<td className="border px-2 py-1 text-center bg-muted/50">출하</td>
|
||||
<td className="border px-2 py-1 text-center bg-muted/50">품질</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* 출하 관리부서 */}
|
||||
<div className="text-xs text-muted-foreground mb-2">출하 관리부서</div>
|
||||
|
||||
{/* 연락처 */}
|
||||
<div className="text-xs mb-4">
|
||||
전화 : 031-938-5130 | 팩스 : 02-6911-6315 | 이메일 : kd5130@naver.com
|
||||
</div>
|
||||
|
||||
{/* 발주정보 / 납품정보 */}
|
||||
<div className="grid grid-cols-2 gap-0 mb-6">
|
||||
<div className="border">
|
||||
<div className="bg-muted px-3 py-2 font-medium text-center border-b">발주정보</div>
|
||||
<table className="w-full text-xs">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td className="border-r border-b px-3 py-2 bg-muted w-24">발주일</td>
|
||||
<td className="border-b px-3 py-2">-</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="border-r border-b px-3 py-2 bg-muted">발주처</td>
|
||||
<td className="border-b px-3 py-2">{data.customerName}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="border-r border-b px-3 py-2 bg-muted">담당자</td>
|
||||
<td className="border-b px-3 py-2">-</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="border-r border-b px-3 py-2 bg-muted">연락처</td>
|
||||
<td className="border-b px-3 py-2">-</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="border-r px-3 py-2 bg-muted">제품 LOT NO.</td>
|
||||
<td className="px-3 py-2">{data.lotNo}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="border border-l-0">
|
||||
<div className="bg-muted px-3 py-2 font-medium text-center border-b">납품정보</div>
|
||||
<table className="w-full text-xs">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td className="border-r border-b px-3 py-2 bg-muted w-24">납품일</td>
|
||||
<td className="border-b px-3 py-2">{data.scheduledDate}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="border-r border-b px-3 py-2 bg-muted">현장명</td>
|
||||
<td className="border-b px-3 py-2">{data.siteName}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="border-r border-b px-3 py-2 bg-muted">인수업체명</td>
|
||||
<td className="border-b px-3 py-2">현판업</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="border-r border-b px-3 py-2 bg-muted">인수자연락처</td>
|
||||
<td className="border-b px-3 py-2">{data.receiverContact}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="border-r px-3 py-2 bg-muted">납품지 주소</td>
|
||||
<td className="px-3 py-2">{data.deliveryAddress}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 납품 품목 */}
|
||||
<h3 className="font-medium mb-2">납품 품목</h3>
|
||||
<table className="w-full border-collapse mb-6 text-xs">
|
||||
<thead>
|
||||
<tr className="bg-muted">
|
||||
<th className="border px-2 py-2 text-center w-12">No</th>
|
||||
<th className="border px-2 py-2 text-left">품 명</th>
|
||||
<th className="border px-2 py-2 text-center w-24">규 격</th>
|
||||
<th className="border px-2 py-2 text-center w-16">단위</th>
|
||||
<th className="border px-2 py-2 text-center w-16">수량</th>
|
||||
<th className="border px-2 py-2 text-center w-20">비고</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.products.map((product, index) => (
|
||||
<tr key={product.id}>
|
||||
<td className="border px-2 py-2 text-center">{product.no}</td>
|
||||
<td className="border px-2 py-2">{product.itemName}</td>
|
||||
<td className="border px-2 py-2 text-center">{product.specification}</td>
|
||||
<td className="border px-2 py-2 text-center">SET</td>
|
||||
<td className="border px-2 py-2 text-center">{product.quantity}</td>
|
||||
<td className="border px-2 py-2 text-center">{product.floorUnit}</td>
|
||||
</tr>
|
||||
))}
|
||||
{/* 빈 행 채우기 (최소 10행) */}
|
||||
{Array.from({ length: Math.max(0, 10 - data.products.length) }).map((_, i) => (
|
||||
<tr key={`empty-${i}`}>
|
||||
<td className="border px-2 py-2 text-center">{data.products.length + i + 1}</td>
|
||||
<td className="border px-2 py-2"> </td>
|
||||
<td className="border px-2 py-2"></td>
|
||||
<td className="border px-2 py-2"></td>
|
||||
<td className="border px-2 py-2"></td>
|
||||
<td className="border px-2 py-2"></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{/* 특기사항 */}
|
||||
<div className="border p-4 mb-6">
|
||||
<h3 className="font-medium mb-2">특기사항</h3>
|
||||
<div className="min-h-16 text-xs">
|
||||
위 물품을 상기와 같이 납품합니다.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 서명 영역 */}
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
{/* 납품자 */}
|
||||
<div className="border p-4">
|
||||
<h3 className="font-medium mb-3 text-center">납 품 자</h3>
|
||||
<table className="w-full text-xs">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td className="py-2 w-20">회 사 명</td>
|
||||
<td className="py-2 font-medium">경동기업</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="py-2">담 당 자</td>
|
||||
<td className="py-2">전진</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="py-2">서명/날인</td>
|
||||
<td className="py-2"></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* 인수자 */}
|
||||
<div className="border p-4">
|
||||
<h3 className="font-medium mb-3 text-center">인 수 자</h3>
|
||||
<table className="w-full text-xs">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td className="py-2 w-20">회 사 명</td>
|
||||
<td className="py-2"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="py-2">담 당 자</td>
|
||||
<td className="py-2"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="py-2">서명/날인</td>
|
||||
<td className="py-2"></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 확인 문구 */}
|
||||
<p className="text-center text-xs text-muted-foreground mt-6">
|
||||
상기 물품을 정히 인수하였음을 확인합니다.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 출고증 미리보기/인쇄 문서
|
||||
*/
|
||||
|
||||
import type { ShipmentDetail } from '../types';
|
||||
|
||||
interface ShippingSlipProps {
|
||||
data: ShipmentDetail;
|
||||
}
|
||||
|
||||
export function ShippingSlip({ data }: ShippingSlipProps) {
|
||||
return (
|
||||
<div className="bg-white p-8 max-w-4xl mx-auto text-sm print:p-0 print:max-w-none">
|
||||
{/* 헤더 */}
|
||||
<div className="flex justify-between items-start mb-6 border-b pb-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="text-2xl font-bold">KD</div>
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">경동기업</div>
|
||||
<div className="text-xs text-muted-foreground">KYUNGDONG COMPANY</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-2xl font-bold tracking-widest">출 고 증</div>
|
||||
<table className="text-xs border-collapse">
|
||||
<tbody>
|
||||
{/* 헤더: 결재 + 작성/검토/승인 */}
|
||||
<tr>
|
||||
<td className="border px-2 py-1 bg-muted" rowSpan={3}>
|
||||
<div className="flex flex-col items-center justify-center h-full">
|
||||
<span>결</span>
|
||||
<span>재</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="border px-2 py-1 bg-muted text-center w-20">작성</td>
|
||||
<td className="border px-2 py-1 bg-muted text-center w-16">검토</td>
|
||||
<td className="border px-2 py-1 bg-muted text-center w-16">승인</td>
|
||||
</tr>
|
||||
{/* 내용: 담당자 정보 */}
|
||||
<tr>
|
||||
<td className="border px-2 py-1 text-center">
|
||||
<div>판매1팀 임</div>
|
||||
<div>판매</div>
|
||||
<div className="text-muted-foreground">12-20</div>
|
||||
</td>
|
||||
<td className="border px-2 py-1 text-center"></td>
|
||||
<td className="border px-2 py-1 text-center"></td>
|
||||
</tr>
|
||||
{/* 부서 */}
|
||||
<tr>
|
||||
<td className="border px-2 py-1 text-center bg-muted/50">판매/전진</td>
|
||||
<td className="border px-2 py-1 text-center bg-muted/50">출하</td>
|
||||
<td className="border px-2 py-1 text-center bg-muted/50">생산관리</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* 출하 관리 */}
|
||||
<div className="text-xs text-muted-foreground mb-2">출하 관리</div>
|
||||
|
||||
{/* LOT 및 연락처 정보 */}
|
||||
<div className="grid grid-cols-5 gap-2 mb-4 text-xs">
|
||||
<div className="border px-2 py-1 bg-muted font-medium">제품 LOT NO.</div>
|
||||
<div className="border px-2 py-1">{data.lotNo}</div>
|
||||
<div className="border px-2 py-1 col-span-3">
|
||||
전화 : 031-938-5130 | 팩스 : 02-6911-6315 | 이메일 : kd5130@naver.com
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 품목 정보 */}
|
||||
<div className="grid grid-cols-5 gap-0 mb-4 text-xs border">
|
||||
<div className="border px-2 py-1 bg-muted font-medium">상품명</div>
|
||||
<div className="border px-2 py-1">국산철물스크린폴리스페서</div>
|
||||
<div className="border px-2 py-1 bg-muted font-medium">제품코드</div>
|
||||
<div className="border px-2 py-1">KWE01</div>
|
||||
<div className="border px-2 py-1 bg-muted font-medium">단발호</div>
|
||||
<div className="border px-2 py-1 col-span-4">FDS-0T523-0117-4</div>
|
||||
</div>
|
||||
|
||||
{/* 신청업체 / 신청내용 / 납품정보 */}
|
||||
<table className="w-full border-collapse mb-6 text-xs">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="border px-2 py-1 bg-muted text-center" colSpan={2}>신청업체</th>
|
||||
<th className="border px-2 py-1 bg-muted text-center" colSpan={2}>신청내용</th>
|
||||
<th className="border px-2 py-1 bg-muted text-center" colSpan={2}>납품정보</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td className="border px-2 py-1 bg-muted">발주일</td>
|
||||
<td className="border px-2 py-1">-</td>
|
||||
<td className="border px-2 py-1 bg-muted">현장</td>
|
||||
<td className="border px-2 py-1">위브 청라</td>
|
||||
<td className="border px-2 py-1 bg-muted">인수업체</td>
|
||||
<td className="border px-2 py-1">{data.customerName}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="border px-2 py-1 bg-muted">발주처</td>
|
||||
<td className="border px-2 py-1">{data.customerName}</td>
|
||||
<td className="border px-2 py-1 bg-muted">납기일정일</td>
|
||||
<td className="border px-2 py-1">-</td>
|
||||
<td className="border px-2 py-1 bg-muted">인수자연락처</td>
|
||||
<td className="border px-2 py-1">{data.receiverContact}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="border px-2 py-1 bg-muted">발주 담당자</td>
|
||||
<td className="border px-2 py-1">-</td>
|
||||
<td className="border px-2 py-1 bg-muted">출고일</td>
|
||||
<td className="border px-2 py-1">{data.scheduledDate}</td>
|
||||
<td className="border px-2 py-1 bg-muted">인수자</td>
|
||||
<td className="border px-2 py-1">{data.receiver || '-'}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="border px-2 py-1 bg-muted">담당자 연락처</td>
|
||||
<td className="border px-2 py-1">-</td>
|
||||
<td className="border px-2 py-1 bg-muted">세미물류수</td>
|
||||
<td className="border px-2 py-1">-</td>
|
||||
<td className="border px-2 py-1 bg-muted">출고 방법</td>
|
||||
<td className="border px-2 py-1">직접배차 직접</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="border px-2 py-1 bg-muted" colSpan={2}>배송지 주소</td>
|
||||
<td className="border px-2 py-1" colSpan={4}>{data.deliveryAddress}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{/* 1. 부자재 */}
|
||||
<div className="mb-4">
|
||||
<h3 className="font-medium mb-2">1. 부자재 - 강기샤프트, 라파이프, 앵글</h3>
|
||||
<table className="w-full border-collapse text-xs">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="border px-2 py-1 bg-muted">강기샤프트</th>
|
||||
<th className="border px-2 py-1 bg-muted">수량</th>
|
||||
<th className="border px-2 py-1 bg-muted">강기샤프트</th>
|
||||
<th className="border px-2 py-1 bg-muted">수량</th>
|
||||
<th className="border px-2 py-1 bg-muted">라파이프</th>
|
||||
<th className="border px-2 py-1 bg-muted">수량</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td className="border px-2 py-1">4인치<br />L : 3,000<br />L : 4,500</td>
|
||||
<td className="border px-2 py-1 text-center">1</td>
|
||||
<td className="border px-2 py-1">5인치<br />L : 6,000<br />L : 7,000</td>
|
||||
<td className="border px-2 py-1 text-center">0</td>
|
||||
<td className="border px-2 py-1">(50*30*1.4T)<br />L : 6,000</td>
|
||||
<td className="border px-2 py-1 text-center">5</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="border px-2 py-1" colSpan={4}>※ 별도 추가사항 - 부자재</td>
|
||||
<td className="border px-2 py-1 bg-muted">앵글<br />(40*40*3T)</td>
|
||||
<td className="border px-2 py-1 text-center">4</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* 2. 모터 */}
|
||||
<div className="mb-6">
|
||||
<h3 className="font-medium mb-2">2. 모터</h3>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<table className="w-full border-collapse text-xs">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="border px-2 py-1 bg-muted" colSpan={3}>2-1. 모터(220V 단상)</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<th className="border px-2 py-1 bg-muted">모터 용량</th>
|
||||
<th className="border px-2 py-1 bg-muted">수량</th>
|
||||
<th className="border px-2 py-1 bg-muted">입고 LOT NO.</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td className="border px-2 py-1">KD-300K</td>
|
||||
<td className="border px-2 py-1 text-center">0</td>
|
||||
<td className="border px-2 py-1"></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<table className="w-full border-collapse text-xs">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="border px-2 py-1 bg-muted" colSpan={3}>2-2. 모터(380V 삼상)</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<th className="border px-2 py-1 bg-muted">모터 용량</th>
|
||||
<th className="border px-2 py-1 bg-muted">수량</th>
|
||||
<th className="border px-2 py-1 bg-muted">입고 LOT NO.</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td className="border px-2 py-1">KD-300K<br />KD-400K</td>
|
||||
<td className="border px-2 py-1 text-center">0</td>
|
||||
<td className="border px-2 py-1"></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4 mt-4">
|
||||
<table className="w-full border-collapse text-xs">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="border px-2 py-1 bg-muted" colSpan={2}>2-3. 브라켓트</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td className="border px-2 py-1">380*180 (2-4")</td>
|
||||
<td className="border px-2 py-1 text-center">0</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="border px-2 py-1">380*180 (2-5")</td>
|
||||
<td className="border px-2 py-1 text-center">0</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<table className="w-full border-collapse text-xs">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="border px-2 py-1 bg-muted" colSpan={2}>2-4. 연동파이프</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td className="border px-2 py-1">매립형</td>
|
||||
<td className="border px-2 py-1 text-center">1</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="border px-2 py-1">노출형</td>
|
||||
<td className="border px-2 py-1 text-center">0</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 버튼 */}
|
||||
<div className="flex justify-end gap-2 print:hidden">
|
||||
<button className="px-4 py-1 border rounded text-sm">출고 담당</button>
|
||||
<button className="px-4 py-1 border rounded text-sm">인수 확인</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 거래명세서 미리보기/인쇄 문서
|
||||
*/
|
||||
|
||||
import type { ShipmentDetail } from '../types';
|
||||
|
||||
interface TransactionStatementProps {
|
||||
data: ShipmentDetail;
|
||||
}
|
||||
|
||||
export function TransactionStatement({ data }: TransactionStatementProps) {
|
||||
// 제품 합계 계산
|
||||
const totalAmount = data.products.reduce((sum, product) => {
|
||||
// 실제로는 단가 * 수량
|
||||
return sum + 0;
|
||||
}, 0);
|
||||
|
||||
return (
|
||||
<div className="bg-white p-8 max-w-3xl mx-auto text-sm print:p-0 print:max-w-none">
|
||||
{/* 제목 */}
|
||||
<h1 className="text-2xl font-bold text-center tracking-[1rem] mb-8">
|
||||
거 래 명 세 서
|
||||
</h1>
|
||||
<p className="text-center text-xs text-muted-foreground mb-6">
|
||||
TRANSACTION STATEMENT
|
||||
</p>
|
||||
|
||||
{/* 공급받는자 / 공급자 정보 */}
|
||||
<div className="grid grid-cols-2 gap-6 mb-6">
|
||||
{/* 공급받는자 */}
|
||||
<div className="border p-4">
|
||||
<h2 className="font-bold mb-3 text-muted-foreground">공급받는자</h2>
|
||||
<table className="w-full text-xs">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td className="py-1 w-16">상 호</td>
|
||||
<td className="py-1 font-medium">{data.customerName}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="py-1">현장명</td>
|
||||
<td className="py-1">{data.siteName}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="py-1">주 소</td>
|
||||
<td className="py-1">{data.deliveryAddress}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* 공급자 */}
|
||||
<div className="border p-4">
|
||||
<h2 className="font-bold mb-3 text-muted-foreground">공급자</h2>
|
||||
<table className="w-full text-xs">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td className="py-1 w-16">상 호</td>
|
||||
<td className="py-1 font-medium">경동기업</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="py-1">대표자</td>
|
||||
<td className="py-1">홍길동</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="py-1">주 소</td>
|
||||
<td className="py-1">경기도 화성시 팔탄면 산업로 123</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="py-1">연락처</td>
|
||||
<td className="py-1">031-123-4567</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 거래일자 / 출고번호 */}
|
||||
<div className="grid grid-cols-2 gap-6 mb-6 text-xs">
|
||||
<div className="flex">
|
||||
<span className="font-medium mr-4">거래일자</span>
|
||||
<span>{data.scheduledDate}</span>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<span className="font-medium mr-4">출고번호</span>
|
||||
<span>{data.shipmentNo}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 품목 테이블 */}
|
||||
<table className="w-full border-collapse mb-6 text-xs">
|
||||
<thead>
|
||||
<tr className="bg-muted">
|
||||
<th className="border px-2 py-2 text-center w-12">No</th>
|
||||
<th className="border px-2 py-2 text-left">품 목 명</th>
|
||||
<th className="border px-2 py-2 text-center w-24">규 격</th>
|
||||
<th className="border px-2 py-2 text-center w-16">수량</th>
|
||||
<th className="border px-2 py-2 text-center w-20">단가</th>
|
||||
<th className="border px-2 py-2 text-center w-20">금액</th>
|
||||
<th className="border px-2 py-2 text-center w-20">비고</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.products.map((product, index) => (
|
||||
<tr key={product.id}>
|
||||
<td className="border px-2 py-2 text-center">{product.no}</td>
|
||||
<td className="border px-2 py-2">{product.itemName}</td>
|
||||
<td className="border px-2 py-2 text-center">{product.specification}</td>
|
||||
<td className="border px-2 py-2 text-center">{product.quantity}</td>
|
||||
<td className="border px-2 py-2 text-right">0</td>
|
||||
<td className="border px-2 py-2 text-right">0</td>
|
||||
<td className="border px-2 py-2 text-center">{product.floorUnit}</td>
|
||||
</tr>
|
||||
))}
|
||||
{/* 빈 행 채우기 */}
|
||||
{Array.from({ length: Math.max(0, 5 - data.products.length) }).map((_, i) => (
|
||||
<tr key={`empty-${i}`}>
|
||||
<td className="border px-2 py-2"> </td>
|
||||
<td className="border px-2 py-2"></td>
|
||||
<td className="border px-2 py-2"></td>
|
||||
<td className="border px-2 py-2"></td>
|
||||
<td className="border px-2 py-2"></td>
|
||||
<td className="border px-2 py-2"></td>
|
||||
<td className="border px-2 py-2"></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr className="bg-muted">
|
||||
<td className="border px-2 py-2 text-center font-medium" colSpan={5}>
|
||||
합 계
|
||||
</td>
|
||||
<td className="border px-2 py-2 text-right font-medium">{totalAmount}</td>
|
||||
<td className="border px-2 py-2"></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
|
||||
{/* 확인 문구 */}
|
||||
<p className="text-center text-sm text-muted-foreground mb-4">
|
||||
위와 같이 거래하였음을 확인합니다.
|
||||
</p>
|
||||
|
||||
{/* 회사명 */}
|
||||
<p className="text-center text-lg font-bold">경동기업</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
11
src/components/outbound/ShipmentManagement/index.ts
Normal file
11
src/components/outbound/ShipmentManagement/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* 출하관리 컴포넌트 Export
|
||||
*/
|
||||
|
||||
export { ShipmentList } from './ShipmentList';
|
||||
export { ShipmentCreate } from './ShipmentCreate';
|
||||
export { ShipmentDetail } from './ShipmentDetail';
|
||||
export { ShipmentEdit } from './ShipmentEdit';
|
||||
|
||||
// Types
|
||||
export * from './types';
|
||||
289
src/components/outbound/ShipmentManagement/mockData.ts
Normal file
289
src/components/outbound/ShipmentManagement/mockData.ts
Normal file
@@ -0,0 +1,289 @@
|
||||
/**
|
||||
* 출하관리 Mock 데이터
|
||||
*/
|
||||
|
||||
import type {
|
||||
ShipmentItem,
|
||||
ShipmentDetail,
|
||||
ShipmentStats,
|
||||
ShipmentFilterTab,
|
||||
LotOption,
|
||||
LogisticsOption,
|
||||
VehicleTonnageOption,
|
||||
} from './types';
|
||||
|
||||
// 통계 데이터
|
||||
export const mockStats: ShipmentStats = {
|
||||
todayShipmentCount: 3,
|
||||
scheduledCount: 5,
|
||||
shippingCount: 1,
|
||||
urgentCount: 4,
|
||||
};
|
||||
|
||||
// 필터 탭
|
||||
export const mockFilterTabs: ShipmentFilterTab[] = [
|
||||
{ key: 'all', label: '전체', count: 20 },
|
||||
{ key: 'scheduled', label: '출고예정', count: 5 },
|
||||
{ key: 'ready', label: '출하대기', count: 1 },
|
||||
{ key: 'shipping', label: '배송중', count: 1 },
|
||||
{ key: 'completed', label: '배송완료', count: 12 },
|
||||
{ key: 'calendar', label: '출하일정', count: 0 },
|
||||
];
|
||||
|
||||
// 출하 목록 Mock 데이터
|
||||
export const mockShipmentItems: ShipmentItem[] = [
|
||||
{
|
||||
id: 'sl-251220-01',
|
||||
shipmentNo: 'SL-251220-01',
|
||||
lotNo: 'KD-TS-251217-10',
|
||||
scheduledDate: '2025-12-20',
|
||||
status: 'completed',
|
||||
priority: 'urgent',
|
||||
deliveryMethod: 'direct',
|
||||
customerName: '두산건설(주)',
|
||||
siteName: '위브 청라',
|
||||
manager: '-',
|
||||
canShip: true,
|
||||
depositConfirmed: true,
|
||||
invoiceIssued: true,
|
||||
},
|
||||
{
|
||||
id: 'sl-251222-03',
|
||||
shipmentNo: 'SL-251222-03',
|
||||
lotNo: 'KD-TS-251217-09',
|
||||
scheduledDate: '2025-12-22',
|
||||
status: 'completed',
|
||||
priority: 'normal',
|
||||
deliveryMethod: 'logistics',
|
||||
customerName: '태영건설(주)',
|
||||
siteName: '대시앙 동탄',
|
||||
manager: '-',
|
||||
canShip: true,
|
||||
depositConfirmed: true,
|
||||
invoiceIssued: true,
|
||||
},
|
||||
{
|
||||
id: 'sl-251224-01',
|
||||
shipmentNo: 'SL-251224-01',
|
||||
lotNo: 'KD-TS-251217-08',
|
||||
scheduledDate: '2025-12-24',
|
||||
status: 'completed',
|
||||
priority: 'normal',
|
||||
deliveryMethod: 'logistics',
|
||||
customerName: '호반건설(주)',
|
||||
siteName: '버킷 광교',
|
||||
manager: '-',
|
||||
canShip: true,
|
||||
depositConfirmed: true,
|
||||
invoiceIssued: true,
|
||||
},
|
||||
{
|
||||
id: 'sl-251221-03',
|
||||
shipmentNo: 'SL-251221-03',
|
||||
lotNo: 'KD-TS-251217-06',
|
||||
scheduledDate: '2025-12-21',
|
||||
status: 'shipping',
|
||||
priority: 'normal',
|
||||
deliveryMethod: 'pickup',
|
||||
customerName: '호반건설(주)',
|
||||
siteName: '버킷 광교',
|
||||
manager: '-',
|
||||
canShip: true,
|
||||
depositConfirmed: true,
|
||||
invoiceIssued: true,
|
||||
},
|
||||
{
|
||||
id: 'sl-251223-02',
|
||||
shipmentNo: 'SL-251223-02',
|
||||
lotNo: 'KD-TS-251217-05',
|
||||
scheduledDate: '2025-12-23',
|
||||
status: 'ready',
|
||||
priority: 'normal',
|
||||
deliveryMethod: 'direct',
|
||||
customerName: '포스코건설(주)',
|
||||
siteName: '더샵 송도',
|
||||
manager: '-',
|
||||
canShip: true,
|
||||
depositConfirmed: true,
|
||||
invoiceIssued: true,
|
||||
},
|
||||
{
|
||||
id: 'sl-251222-02',
|
||||
shipmentNo: 'SL-251222-02',
|
||||
lotNo: 'KD-TS-251217-04',
|
||||
scheduledDate: '2025-12-22',
|
||||
status: 'completed',
|
||||
priority: 'normal',
|
||||
deliveryMethod: 'direct',
|
||||
customerName: 'GS건설(주)',
|
||||
siteName: '자이 위례',
|
||||
manager: '-',
|
||||
canShip: true,
|
||||
depositConfirmed: true,
|
||||
invoiceIssued: true,
|
||||
},
|
||||
{
|
||||
id: 'sl-251222-01',
|
||||
shipmentNo: 'SL-251222-01',
|
||||
lotNo: 'KD-TS-251217-03',
|
||||
scheduledDate: '2025-12-22',
|
||||
status: 'completed',
|
||||
priority: 'normal',
|
||||
deliveryMethod: 'pickup',
|
||||
customerName: '대우건설(주)',
|
||||
siteName: '푸르지오 일산',
|
||||
manager: '-',
|
||||
canShip: true,
|
||||
depositConfirmed: true,
|
||||
invoiceIssued: true,
|
||||
},
|
||||
{
|
||||
id: 'sl-251223-02b',
|
||||
shipmentNo: 'SL-251223-02',
|
||||
lotNo: 'KD-TS-251217-02',
|
||||
scheduledDate: '2025-12-23',
|
||||
status: 'scheduled',
|
||||
priority: 'normal',
|
||||
deliveryMethod: 'logistics',
|
||||
customerName: '현대건설(주)',
|
||||
siteName: '힐스테이트 판교',
|
||||
manager: '-',
|
||||
canShip: false,
|
||||
depositConfirmed: false,
|
||||
invoiceIssued: false,
|
||||
},
|
||||
{
|
||||
id: 'sl-251221-02',
|
||||
shipmentNo: 'SL-251221-02',
|
||||
lotNo: 'KD-TS-251217-02',
|
||||
scheduledDate: '2025-12-23',
|
||||
status: 'scheduled',
|
||||
priority: 'normal',
|
||||
deliveryMethod: 'pickup',
|
||||
customerName: '현대건설(주)',
|
||||
siteName: '힐스테이트 판교',
|
||||
manager: '-',
|
||||
canShip: false,
|
||||
depositConfirmed: true,
|
||||
invoiceIssued: false,
|
||||
},
|
||||
{
|
||||
id: 'sl-251221-01',
|
||||
shipmentNo: 'SL-251221-01',
|
||||
lotNo: 'KD-TS-251217-01',
|
||||
scheduledDate: '2025-12-21',
|
||||
status: 'scheduled',
|
||||
priority: 'normal',
|
||||
deliveryMethod: 'pickup',
|
||||
customerName: '삼성물산(주)',
|
||||
siteName: '래미안 강남 포레스트',
|
||||
manager: '-',
|
||||
canShip: false,
|
||||
depositConfirmed: false,
|
||||
invoiceIssued: false,
|
||||
},
|
||||
];
|
||||
|
||||
// 출하 상세 Mock 데이터
|
||||
export const mockShipmentDetail: ShipmentDetail = {
|
||||
id: 'sl-251220-01',
|
||||
shipmentNo: 'SL-251220-01',
|
||||
lotNo: 'KD-TS-251217-10',
|
||||
scheduledDate: '2025-12-20',
|
||||
status: 'completed',
|
||||
priority: 'urgent',
|
||||
deliveryMethod: 'direct',
|
||||
depositConfirmed: true,
|
||||
invoiceIssued: true,
|
||||
customerGrade: 'B등급',
|
||||
canShip: true,
|
||||
loadingManager: '김상차',
|
||||
loadingCompleted: '2025.12.20.',
|
||||
registrant: '판매1팀 임판매',
|
||||
|
||||
customerName: '두산건설(주)',
|
||||
siteName: '위브 청라',
|
||||
deliveryAddress: '인천시 서구 청라동 456',
|
||||
receiver: '임현장',
|
||||
receiverContact: '010-8901-2345',
|
||||
|
||||
products: [
|
||||
{
|
||||
id: 'prod-1',
|
||||
no: 1,
|
||||
itemCode: 'SH3225',
|
||||
itemName: '스크린 셔터 (와이어)',
|
||||
floorUnit: '18층/M-01',
|
||||
specification: '3200×2500',
|
||||
quantity: 1,
|
||||
lotNo: 'LOT-20251217-001',
|
||||
},
|
||||
],
|
||||
|
||||
logisticsCompany: '-',
|
||||
vehicleTonnage: '3.5톤',
|
||||
shippingCost: undefined,
|
||||
|
||||
vehicleNo: '34사 5678',
|
||||
driverName: '정운전',
|
||||
driverContact: '010-5656-7878',
|
||||
|
||||
remarks: '[통합테스트10] 두산건설 - 전체 플로우 완료 (견적→수주→생산→품질→출하→회계)',
|
||||
};
|
||||
|
||||
// LOT 선택 옵션 (등록 시)
|
||||
export const mockLotOptions: LotOption[] = [
|
||||
{
|
||||
value: 'KD-TS-251217-10',
|
||||
label: 'KD-TS-251217-10',
|
||||
customerName: '두산건설(주)',
|
||||
siteName: '위브 청라',
|
||||
deliveryAddress: '인천시 서구 청라동 456',
|
||||
},
|
||||
{
|
||||
value: 'KD-TS-251217-09',
|
||||
label: 'KD-TS-251217-09',
|
||||
customerName: '태영건설(주)',
|
||||
siteName: '대시앙 동탄',
|
||||
deliveryAddress: '경기도 화성시 동탄순환대로 100',
|
||||
},
|
||||
{
|
||||
value: 'KD-TS-251217-08',
|
||||
label: 'KD-TS-251217-08',
|
||||
customerName: '호반건설(주)',
|
||||
siteName: '버킷 광교',
|
||||
deliveryAddress: '경기도 수원시 영통구 광교로 200',
|
||||
},
|
||||
];
|
||||
|
||||
// 물류사 옵션
|
||||
export const mockLogisticsOptions: LogisticsOption[] = [
|
||||
{ value: 'hanjin', label: '한진물류' },
|
||||
{ value: 'cj', label: 'CJ대한통운' },
|
||||
{ value: 'lotte', label: '롯데글로벌로지스' },
|
||||
{ value: 'hyundai', label: '현대글로비스' },
|
||||
];
|
||||
|
||||
// 차량 톤수 옵션
|
||||
export const mockVehicleTonnageOptions: VehicleTonnageOption[] = [
|
||||
{ value: '1t', label: '1톤' },
|
||||
{ value: '2.5t', label: '2.5톤' },
|
||||
{ value: '3.5t', label: '3.5톤' },
|
||||
{ value: '5t', label: '5톤' },
|
||||
{ value: '11t', label: '11톤' },
|
||||
{ value: '25t', label: '25톤' },
|
||||
];
|
||||
|
||||
// 우선순위 옵션
|
||||
export const priorityOptions = [
|
||||
{ value: 'urgent', label: '긴급' },
|
||||
{ value: 'normal', label: '보통' },
|
||||
{ value: 'low', label: '낮음' },
|
||||
];
|
||||
|
||||
// 배송방식 옵션
|
||||
export const deliveryMethodOptions = [
|
||||
{ value: 'pickup', label: '상차' },
|
||||
{ value: 'direct', label: '직접배차' },
|
||||
{ value: 'logistics', label: '물류사' },
|
||||
];
|
||||
187
src/components/outbound/ShipmentManagement/types.ts
Normal file
187
src/components/outbound/ShipmentManagement/types.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
/**
|
||||
* 출하관리 타입 정의
|
||||
*/
|
||||
|
||||
// 출하 상태
|
||||
export type ShipmentStatus =
|
||||
| 'scheduled' // 출고예정
|
||||
| 'ready' // 출하대기
|
||||
| 'shipping' // 배송중
|
||||
| 'completed'; // 배송완료
|
||||
|
||||
// 상태 라벨
|
||||
export const SHIPMENT_STATUS_LABELS: Record<ShipmentStatus, string> = {
|
||||
scheduled: '출고예정',
|
||||
ready: '출하대기',
|
||||
shipping: '배송중',
|
||||
completed: '배송완료',
|
||||
};
|
||||
|
||||
// 상태 스타일
|
||||
export const SHIPMENT_STATUS_STYLES: Record<ShipmentStatus, string> = {
|
||||
scheduled: 'bg-gray-100 text-gray-800',
|
||||
ready: 'bg-yellow-100 text-yellow-800',
|
||||
shipping: 'bg-blue-100 text-blue-800',
|
||||
completed: 'bg-green-100 text-green-800',
|
||||
};
|
||||
|
||||
// 출고 우선순위
|
||||
export type ShipmentPriority = 'urgent' | 'normal' | 'low';
|
||||
|
||||
export const PRIORITY_LABELS: Record<ShipmentPriority, string> = {
|
||||
urgent: '긴급',
|
||||
normal: '보통',
|
||||
low: '낮음',
|
||||
};
|
||||
|
||||
export const PRIORITY_STYLES: Record<ShipmentPriority, string> = {
|
||||
urgent: 'bg-red-100 text-red-800',
|
||||
normal: 'bg-gray-100 text-gray-800',
|
||||
low: 'bg-blue-100 text-blue-800',
|
||||
};
|
||||
|
||||
// 배송방식
|
||||
export type DeliveryMethod = 'pickup' | 'direct' | 'logistics';
|
||||
|
||||
export const DELIVERY_METHOD_LABELS: Record<DeliveryMethod, string> = {
|
||||
pickup: '상차',
|
||||
direct: '직접배차',
|
||||
logistics: '물류사',
|
||||
};
|
||||
|
||||
// 출하 목록 아이템
|
||||
export interface ShipmentItem {
|
||||
id: string;
|
||||
shipmentNo: string; // 출고번호
|
||||
lotNo: string; // 로트번호
|
||||
scheduledDate: string; // 출고예정일
|
||||
status: ShipmentStatus; // 상태
|
||||
priority: ShipmentPriority; // 우선순위
|
||||
deliveryMethod: DeliveryMethod; // 배송방식
|
||||
customerName: string; // 발주처
|
||||
siteName: string; // 현장명
|
||||
manager?: string; // 담당
|
||||
canShip: boolean; // 출하가능
|
||||
depositConfirmed: boolean; // 입금확인
|
||||
invoiceIssued: boolean; // 세금계산서
|
||||
deliveryTime?: string; // 납기(수배시간)
|
||||
}
|
||||
|
||||
// 출고 품목
|
||||
export interface ShipmentProduct {
|
||||
id: string;
|
||||
no: number;
|
||||
itemCode: string; // 품목코드
|
||||
itemName: string; // 품목명
|
||||
floorUnit: string; // 층/M호
|
||||
specification: string; // 규격
|
||||
quantity: number; // 수량
|
||||
lotNo: string; // LOT번호
|
||||
}
|
||||
|
||||
// 출하 상세 정보
|
||||
export interface ShipmentDetail {
|
||||
id: string;
|
||||
shipmentNo: string; // 출고번호
|
||||
lotNo: string; // 로트번호
|
||||
scheduledDate: string; // 출고예정일
|
||||
status: ShipmentStatus; // 출고상태
|
||||
priority: ShipmentPriority; // 출고 우선순위
|
||||
deliveryMethod: DeliveryMethod; // 배송방식
|
||||
depositConfirmed: boolean; // 입금확인
|
||||
invoiceIssued: boolean; // 세금계산서
|
||||
customerGrade: string; // 거래처 등급
|
||||
canShip: boolean; // 출하가능
|
||||
loadingManager?: string; // 상차담당자
|
||||
loadingCompleted?: string; // 상차완료
|
||||
registrant?: string; // 등록자
|
||||
|
||||
// 발주처/배송 정보
|
||||
customerName: string; // 발주처
|
||||
siteName: string; // 현장명
|
||||
deliveryAddress: string; // 배송주소
|
||||
receiver?: string; // 인수자
|
||||
receiverContact?: string; // 연락처
|
||||
|
||||
// 출고 품목
|
||||
products: ShipmentProduct[];
|
||||
|
||||
// 배차 정보
|
||||
logisticsCompany?: string; // 물류사
|
||||
vehicleTonnage?: string; // 차량 톤수
|
||||
shippingCost?: number; // 운송비
|
||||
|
||||
// 차량/운전자 정보
|
||||
vehicleNo?: string; // 차량번호
|
||||
driverName?: string; // 운전자
|
||||
driverContact?: string; // 운전자 연락처
|
||||
|
||||
remarks?: string; // 비고
|
||||
}
|
||||
|
||||
// 출하 등록 폼 데이터
|
||||
export interface ShipmentCreateFormData {
|
||||
lotNo: string; // 로트번호 *
|
||||
scheduledDate: string; // 출고예정일 *
|
||||
priority: ShipmentPriority; // 출고 우선순위 *
|
||||
deliveryMethod: DeliveryMethod; // 배송방식 *
|
||||
logisticsCompany?: string; // 물류사
|
||||
vehicleTonnage?: string; // 차량 톤수(물량)
|
||||
loadingTime?: string; // 상차시간(입차예정)
|
||||
loadingManager?: string; // 상차담당자
|
||||
remarks?: string; // 비고
|
||||
}
|
||||
|
||||
// 출하 수정 폼 데이터
|
||||
export interface ShipmentEditFormData {
|
||||
scheduledDate: string; // 출고예정일 *
|
||||
priority: ShipmentPriority; // 출고 우선순위 *
|
||||
deliveryMethod: DeliveryMethod; // 배송방식 *
|
||||
loadingManager?: string; // 상차담당자
|
||||
logisticsCompany?: string; // 물류사
|
||||
vehicleTonnage?: string; // 차량 톤수
|
||||
vehicleNo?: string; // 차량번호
|
||||
shippingCost?: number; // 운송비
|
||||
driverName?: string; // 운전자명
|
||||
driverContact?: string; // 운전자 연락처
|
||||
expectedArrival?: string; // 입차예정시간
|
||||
confirmedArrival?: string; // 입차확정시간
|
||||
changeReason: string; // 변경 사유 *
|
||||
remarks?: string; // 비고
|
||||
}
|
||||
|
||||
// 통계 데이터
|
||||
export interface ShipmentStats {
|
||||
todayShipmentCount: number; // 당일 출하
|
||||
scheduledCount: number; // 출고 대기
|
||||
shippingCount: number; // 배송중
|
||||
urgentCount: number; // 긴급 출하
|
||||
}
|
||||
|
||||
// 필터 탭
|
||||
export interface ShipmentFilterTab {
|
||||
key: 'all' | 'scheduled' | 'ready' | 'shipping' | 'completed' | 'calendar';
|
||||
label: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
// LOT 선택 옵션 (등록 시)
|
||||
export interface LotOption {
|
||||
value: string;
|
||||
label: string;
|
||||
customerName: string;
|
||||
siteName: string;
|
||||
deliveryAddress: string;
|
||||
}
|
||||
|
||||
// 물류사 선택 옵션
|
||||
export interface LogisticsOption {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
// 차량 톤수 선택 옵션
|
||||
export interface VehicleTonnageOption {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
@@ -40,6 +40,14 @@ import {
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { toast } from 'sonner';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
|
||||
// 필드명 매핑
|
||||
const FIELD_NAME_MAP: Record<string, string> = {
|
||||
effectiveDate: "적용일",
|
||||
purchasePrice: "입고가",
|
||||
salesPrice: "판매단가",
|
||||
};
|
||||
|
||||
import type {
|
||||
PricingData,
|
||||
@@ -105,7 +113,7 @@ export function PricingFormClient({
|
||||
const [unit, setUnit] = useState(displayUnit);
|
||||
|
||||
// 에러 상태
|
||||
const [errors, setErrors] = useState<Record<string, boolean>>({});
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
|
||||
// 다이얼로그 상태
|
||||
const [showHistoryDialog, setShowHistoryDialog] = useState(false);
|
||||
@@ -187,11 +195,11 @@ export function PricingFormClient({
|
||||
|
||||
// 유효성 검사
|
||||
const validateForm = useCallback(() => {
|
||||
const newErrors: Record<string, boolean> = {};
|
||||
if (!effectiveDate) newErrors.effectiveDate = true;
|
||||
const newErrors: Record<string, string> = {};
|
||||
if (!effectiveDate) newErrors.effectiveDate = "적용일을 선택해주세요";
|
||||
if (purchasePrice <= 0 && salesPrice <= 0) {
|
||||
newErrors.purchasePrice = true;
|
||||
newErrors.salesPrice = true;
|
||||
newErrors.purchasePrice = "입고가 또는 판매단가 중 최소 하나를 입력해주세요";
|
||||
newErrors.salesPrice = "입고가 또는 판매단가 중 최소 하나를 입력해주세요";
|
||||
}
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
@@ -200,10 +208,14 @@ export function PricingFormClient({
|
||||
// 저장 처리
|
||||
const handleSave = async (isRevision = false, revisionReason = '') => {
|
||||
if (!validateForm()) {
|
||||
toast.error('필수 항목을 입력해주세요.');
|
||||
// 페이지 상단으로 스크롤
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
return;
|
||||
}
|
||||
|
||||
// 에러 초기화
|
||||
setErrors({});
|
||||
|
||||
// 수정 모드이고 리비전 있으면 수정 이력 다이얼로그
|
||||
if (isEditMode && initialData && !isRevision &&
|
||||
(initialData.currentRevision > 0 || initialData.isFinal)) {
|
||||
@@ -301,6 +313,35 @@ export function PricingFormClient({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Validation 에러 표시 */}
|
||||
{Object.keys(errors).length > 0 && (
|
||||
<Alert className="bg-red-50 border-red-200 mb-6">
|
||||
<AlertDescription className="text-red-900">
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="text-lg">⚠️</span>
|
||||
<div className="flex-1">
|
||||
<strong className="block mb-2">
|
||||
입력 내용을 확인해주세요 ({Object.keys(errors).length}개 오류)
|
||||
</strong>
|
||||
<ul className="space-y-1 text-sm">
|
||||
{Object.entries(errors).map(([field, message]) => {
|
||||
const fieldName = FIELD_NAME_MAP[field] || field;
|
||||
return (
|
||||
<li key={field} className="flex items-start gap-1">
|
||||
<span>•</span>
|
||||
<span>
|
||||
<strong>{fieldName}</strong>: {message}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* 상태 표시 (수정 모드) */}
|
||||
{isEditMode && initialData && (
|
||||
<div className="mb-4 flex gap-2 justify-end">
|
||||
@@ -383,7 +424,7 @@ export function PricingFormClient({
|
||||
value={effectiveDate}
|
||||
onChange={(e) => {
|
||||
setEffectiveDate(e.target.value);
|
||||
setErrors((prev) => ({ ...prev, effectiveDate: false }));
|
||||
setErrors((prev) => { const n = {...prev}; delete n.effectiveDate; return n; });
|
||||
}}
|
||||
className={errors.effectiveDate ? 'border-red-500' : ''}
|
||||
/>
|
||||
@@ -439,7 +480,7 @@ export function PricingFormClient({
|
||||
value={purchasePrice || ''}
|
||||
onChange={(e) => {
|
||||
setPurchasePrice(parseInt(e.target.value) || 0);
|
||||
setErrors((prev) => ({ ...prev, purchasePrice: false, salesPrice: false }));
|
||||
setErrors((prev) => { const n = {...prev}; delete n.purchasePrice; delete n.salesPrice; return n; });
|
||||
}}
|
||||
placeholder="0"
|
||||
className={errors.purchasePrice ? 'border-red-500 pr-12' : 'pr-12'}
|
||||
@@ -619,7 +660,7 @@ export function PricingFormClient({
|
||||
value={salesPrice || ''}
|
||||
onChange={(e) => {
|
||||
handleSalesPriceChange(parseInt(e.target.value) || 0);
|
||||
setErrors((prev) => ({ ...prev, purchasePrice: false, salesPrice: false }));
|
||||
setErrors((prev) => { const n = {...prev}; delete n.purchasePrice; delete n.salesPrice; return n; });
|
||||
}}
|
||||
placeholder="0"
|
||||
className={errors.salesPrice ? 'border-red-500 pr-12' : 'pr-12'}
|
||||
|
||||
348
src/components/production/ProductionDashboard/index.tsx
Normal file
348
src/components/production/ProductionDashboard/index.tsx
Normal file
@@ -0,0 +1,348 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 생산 현황판 메인 컴포넌트
|
||||
*
|
||||
* 기능:
|
||||
* - 공장별 탭 필터링 (전체/스크린공장/슬랫공장/절곡공장)
|
||||
* - 통계 카드 6개 (전체작업/작업대기/작업중/작업완료/긴급/지연)
|
||||
* - 3컬럼 레이아웃 (긴급작업/지연작업/작업자별현황)
|
||||
*/
|
||||
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Factory, Clock, PlayCircle, CheckCircle2, AlertTriangle, Timer, Users } from 'lucide-react';
|
||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { PageLayout } from '@/components/organisms/PageLayout';
|
||||
import { generateMockWorkOrders, generateMockWorkerStatus } from './mockData';
|
||||
import type { WorkOrder, WorkerStatus, ProcessType, DashboardStats } from './types';
|
||||
import { TAB_OPTIONS, PROCESS_LABELS, STATUS_LABELS } from './types';
|
||||
|
||||
export default function ProductionDashboard() {
|
||||
const router = useRouter();
|
||||
|
||||
// ===== 상태 관리 =====
|
||||
const [selectedTab, setSelectedTab] = useState<ProcessType | 'all'>('all');
|
||||
const [workOrders] = useState<WorkOrder[]>(() => generateMockWorkOrders());
|
||||
const [workerStatus] = useState<WorkerStatus[]>(() => generateMockWorkerStatus());
|
||||
|
||||
// ===== 필터링된 데이터 =====
|
||||
const filteredOrders = useMemo(() => {
|
||||
if (selectedTab === 'all') return workOrders;
|
||||
return workOrders.filter((order) => order.process === selectedTab);
|
||||
}, [workOrders, selectedTab]);
|
||||
|
||||
// ===== 통계 계산 =====
|
||||
const stats: DashboardStats = useMemo(() => {
|
||||
const orders = filteredOrders;
|
||||
return {
|
||||
total: orders.length,
|
||||
waiting: orders.filter((o) => o.status === 'waiting').length,
|
||||
inProgress: orders.filter((o) => o.status === 'inProgress').length,
|
||||
completed: orders.filter((o) => o.status === 'completed').length,
|
||||
urgent: orders.filter((o) => o.isUrgent).length,
|
||||
delayed: orders.filter((o) => o.isDelayed).length,
|
||||
};
|
||||
}, [filteredOrders]);
|
||||
|
||||
// ===== 긴급/지연 작업 필터링 =====
|
||||
const urgentOrders = useMemo(
|
||||
() => filteredOrders.filter((o) => o.isUrgent).slice(0, 5),
|
||||
[filteredOrders]
|
||||
);
|
||||
const delayedOrders = useMemo(
|
||||
() => filteredOrders.filter((o) => o.isDelayed).slice(0, 5),
|
||||
[filteredOrders]
|
||||
);
|
||||
|
||||
// ===== 핸들러 =====
|
||||
const handleOrderClick = (orderNo: string) => {
|
||||
// orderNo (예: KD-WO-251217-12)로 상세 페이지 이동
|
||||
router.push(`/ko/production/work-orders/${encodeURIComponent(orderNo)}`);
|
||||
};
|
||||
|
||||
const handleWorkerScreenClick = () => {
|
||||
router.push('/ko/production/worker-screen');
|
||||
};
|
||||
|
||||
const handleWorkOrderListClick = () => {
|
||||
router.push('/ko/production/work-orders');
|
||||
};
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
<div className="space-y-6">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
|
||||
<Factory className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">생산 현황판</h1>
|
||||
<p className="text-sm text-muted-foreground">공장별 작업 현황을 확인합니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={handleWorkerScreenClick}>
|
||||
<Users className="mr-2 h-4 w-4" />
|
||||
작업자 화면
|
||||
</Button>
|
||||
<Button variant="outline" onClick={handleWorkOrderListClick}>
|
||||
작업지시 목록
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 탭 */}
|
||||
<Tabs value={selectedTab} onValueChange={(v) => setSelectedTab(v as ProcessType | 'all')}>
|
||||
<TabsList>
|
||||
{TAB_OPTIONS.map((tab) => (
|
||||
<TabsTrigger key={tab.value} value={tab.value} className="px-6">
|
||||
{tab.label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
|
||||
{/* 통계 카드 */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4">
|
||||
<StatCard
|
||||
title="전체 작업"
|
||||
value={stats.total}
|
||||
icon={<Factory className="h-4 w-4" />}
|
||||
variant="default"
|
||||
/>
|
||||
<StatCard
|
||||
title="작업 대기"
|
||||
value={stats.waiting}
|
||||
icon={<Clock className="h-4 w-4" />}
|
||||
variant="default"
|
||||
/>
|
||||
<StatCard
|
||||
title="작업중"
|
||||
value={stats.inProgress}
|
||||
icon={<PlayCircle className="h-4 w-4" />}
|
||||
variant="blue"
|
||||
/>
|
||||
<StatCard
|
||||
title="작업 완료"
|
||||
value={stats.completed}
|
||||
icon={<CheckCircle2 className="h-4 w-4" />}
|
||||
variant="green"
|
||||
/>
|
||||
<StatCard
|
||||
title="긴급"
|
||||
value={stats.urgent}
|
||||
icon={<AlertTriangle className="h-4 w-4" />}
|
||||
variant="red"
|
||||
/>
|
||||
<StatCard
|
||||
title="지연"
|
||||
value={stats.delayed}
|
||||
icon={<Timer className="h-4 w-4" />}
|
||||
variant="orange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 3컬럼 레이아웃 */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* 긴급 작업 */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<AlertTriangle className="h-4 w-4 text-red-500" />
|
||||
긴급 작업
|
||||
<Badge variant="destructive" className="ml-auto">
|
||||
{stats.urgent}
|
||||
</Badge>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{urgentOrders.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground text-center py-4">
|
||||
긴급 작업이 없습니다.
|
||||
</p>
|
||||
) : (
|
||||
urgentOrders.map((order) => (
|
||||
<WorkOrderCard
|
||||
key={order.id}
|
||||
order={order}
|
||||
onClick={() => handleOrderClick(order.orderNo)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 지연 작업 */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Timer className="h-4 w-4 text-orange-500" />
|
||||
지연 작업
|
||||
<Badge className="ml-auto bg-orange-100 text-orange-800 hover:bg-orange-100">
|
||||
{stats.delayed}
|
||||
</Badge>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{delayedOrders.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground text-center py-4">
|
||||
지연 작업이 없습니다.
|
||||
</p>
|
||||
) : (
|
||||
delayedOrders.map((order) => (
|
||||
<WorkOrderCard
|
||||
key={order.id}
|
||||
order={order}
|
||||
onClick={() => handleOrderClick(order.orderNo)}
|
||||
showDelay
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 작업자별 현황 */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Users className="h-4 w-4 text-blue-500" />
|
||||
작업자별 현황
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{workerStatus.map((worker) => (
|
||||
<WorkerStatusRow key={worker.id} worker={worker} />
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
// ===== 하위 컴포넌트 =====
|
||||
|
||||
interface StatCardProps {
|
||||
title: string;
|
||||
value: number;
|
||||
icon: React.ReactNode;
|
||||
variant: 'default' | 'blue' | 'green' | 'red' | 'orange';
|
||||
}
|
||||
|
||||
function StatCard({ title, value, icon, variant }: StatCardProps) {
|
||||
const variantClasses = {
|
||||
default: 'bg-gray-50 text-gray-700',
|
||||
blue: 'bg-blue-50 text-blue-700',
|
||||
green: 'bg-green-50 text-green-700',
|
||||
red: 'bg-red-50 text-red-700',
|
||||
orange: 'bg-orange-50 text-orange-700',
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className={variantClasses[variant]}>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium">{title}</span>
|
||||
{icon}
|
||||
</div>
|
||||
<p className="text-2xl font-bold mt-2">{value}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
interface WorkOrderCardProps {
|
||||
order: WorkOrder;
|
||||
onClick: () => void;
|
||||
showDelay?: boolean;
|
||||
}
|
||||
|
||||
function WorkOrderCard({ order, onClick, showDelay }: WorkOrderCardProps) {
|
||||
const statusColors = {
|
||||
waiting: 'bg-gray-100 text-gray-800',
|
||||
inProgress: 'bg-blue-100 text-blue-800',
|
||||
completed: 'bg-green-100 text-green-800',
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
className="p-3 border rounded-lg hover:bg-accent cursor-pointer transition-colors"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
{order.orderNo}
|
||||
</span>
|
||||
<Badge className={`text-xs ${statusColors[order.status]}`}>
|
||||
{STATUS_LABELS[order.status]}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-sm font-medium mt-1 truncate">{order.productName}</p>
|
||||
<p className="text-xs text-muted-foreground truncate">{order.client}</p>
|
||||
</div>
|
||||
<div className="text-right shrink-0">
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{PROCESS_LABELS[order.process]}
|
||||
</Badge>
|
||||
{showDelay && order.delayDays && (
|
||||
<p className="text-xs text-orange-600 mt-1">+{order.delayDays}일 지연</p>
|
||||
)}
|
||||
{order.isUrgent && !showDelay && (
|
||||
<p className="text-xs text-muted-foreground mt-1">순위 {order.priority}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{order.instruction && (
|
||||
<p className="text-xs text-muted-foreground mt-2 truncate">
|
||||
{order.instruction}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface WorkerStatusRowProps {
|
||||
worker: WorkerStatus;
|
||||
}
|
||||
|
||||
function WorkerStatusRow({ worker }: WorkerStatusRowProps) {
|
||||
const progressPercent = worker.assigned > 0
|
||||
? Math.round((worker.completed / worker.assigned) * 100)
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between py-2 border-b last:border-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-full bg-gray-200 flex items-center justify-center text-sm font-medium">
|
||||
{worker.name.charAt(0)}
|
||||
</div>
|
||||
<span className="text-sm font-medium">{worker.name}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-sm">
|
||||
<div className="text-center">
|
||||
<p className="text-xs text-muted-foreground">작업중</p>
|
||||
<p className="font-medium text-blue-600">{worker.inProgress}</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-xs text-muted-foreground">완료</p>
|
||||
<p className="font-medium text-green-600">{worker.completed}</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-xs text-muted-foreground">배정</p>
|
||||
<p className="font-medium">{worker.assigned}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
158
src/components/production/ProductionDashboard/mockData.ts
Normal file
158
src/components/production/ProductionDashboard/mockData.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
import type { WorkOrder, WorkerStatus, ProcessType } from './types';
|
||||
|
||||
// Mock 작업 지시 데이터
|
||||
export const generateMockWorkOrders = (): WorkOrder[] => {
|
||||
const processes: ProcessType[] = ['screen', 'slat', 'bending'];
|
||||
const clients = ['삼성물산(주)', '현대건설(주)', '대림건설(주)', '두산건설(주)', '(주)서울인테리어'];
|
||||
const projects = [
|
||||
'강남 타워 신축현장 (B동)',
|
||||
'강남 오피스 A동',
|
||||
'해운대 타워',
|
||||
'[E2E테스트] 강남 오피스 A동',
|
||||
'대치 레이크파크',
|
||||
'위례 청라 센트럴파크',
|
||||
'판교 물류센터',
|
||||
'삼성타운 종합',
|
||||
'분당 더 피스트',
|
||||
'연수 오피스텔',
|
||||
];
|
||||
const productNames = [
|
||||
'스크린 서터 (표준형) - 추가',
|
||||
'방연셔터 절곡 부품',
|
||||
'철재 슬랫 서터',
|
||||
'스크린 서터 (대형)',
|
||||
];
|
||||
const assigneePool = [
|
||||
'김스크린', '박스크린', '이스크린', '최스크린',
|
||||
'김슬랫', '박슬랫', '이절곡', '박절곡',
|
||||
'이정곡', '김술랫', '박술랫', '이슬랫',
|
||||
];
|
||||
|
||||
const orders: WorkOrder[] = [];
|
||||
|
||||
// 긴급 작업 (5개) - WorkOrders mockData와 매칭
|
||||
const urgentOrders = [
|
||||
{ orderNo: 'KD-WO-251217-12', process: 'screen' as ProcessType, client: '두산건설(주)', project: '위브 청라 센트럴파크', dueDate: '2025-12-30', status: 'completed' as const },
|
||||
{ orderNo: 'KD-WO-251217-11', process: 'screen' as ProcessType, client: '대영건설(주)', project: '대시앙 동탄 레이크파크', dueDate: '2026-02-08', status: 'inProgress' as const },
|
||||
{ orderNo: 'KD-WO-FLD-251216-01', process: 'bending' as ProcessType, client: '삼성물산(주)', project: '[E2E테스트] 절곡 전용 현장', dueDate: '2025-12-28', status: 'inProgress' as const },
|
||||
{ orderNo: 'KD-WO-251217-10', process: 'screen' as ProcessType, client: '포레나', project: '포레나 수지 더 퍼스트', dueDate: '2026-02-13', status: 'waiting' as const },
|
||||
{ orderNo: 'KD-WO-251217-09', process: 'slat' as ProcessType, client: '호반건설(주)', project: '써밋 광교 리버파크', dueDate: '2026-01-30', status: 'inProgress' as const },
|
||||
];
|
||||
|
||||
urgentOrders.forEach((item, i) => {
|
||||
orders.push({
|
||||
id: `urgent-${i + 1}`,
|
||||
orderNo: item.orderNo,
|
||||
productName: productNames[i % productNames.length],
|
||||
process: item.process,
|
||||
client: item.client,
|
||||
projectName: item.project,
|
||||
assignees: [assigneePool[i % assigneePool.length]],
|
||||
quantity: (i % 5) + 2, // 고정값 (2~6)
|
||||
dueDate: item.dueDate,
|
||||
priority: i + 1,
|
||||
status: item.status,
|
||||
isUrgent: true,
|
||||
isDelayed: false,
|
||||
instruction: i === 0 ? '추가분 주문, 기존 납품분과 동일 사양 유지' : undefined,
|
||||
createdAt: '2025-12-20T09:00:00.000Z', // 고정값
|
||||
});
|
||||
});
|
||||
|
||||
// 지연 작업 (5개) - WorkOrders mockData와 매칭
|
||||
const delayedOrders = [
|
||||
{ orderNo: 'KD-WO-251217-08', process: 'screen' as ProcessType, client: '삼성물산(주)', delayDays: 5 },
|
||||
{ orderNo: 'KD-WO-251217-07', process: 'screen' as ProcessType, client: '삼성물산(주)', delayDays: 3 },
|
||||
{ orderNo: 'KD-WO-FLD-251215-01', process: 'bending' as ProcessType, client: '삼성물산(주)', delayDays: 7 },
|
||||
{ orderNo: 'KD-WO-FLD-251212-01', process: 'bending' as ProcessType, client: '삼성물산(주)', delayDays: 10 },
|
||||
{ orderNo: 'KD-WO-FLD-251208-01', process: 'screen' as ProcessType, client: '삼성물산(주)', delayDays: 1 },
|
||||
];
|
||||
|
||||
delayedOrders.forEach((item, i) => {
|
||||
orders.push({
|
||||
id: `delayed-${i + 1}`,
|
||||
orderNo: item.orderNo,
|
||||
productName: productNames[i % productNames.length],
|
||||
process: item.process,
|
||||
client: item.client,
|
||||
projectName: projects[i % projects.length],
|
||||
assignees: [assigneePool[(i + 5) % assigneePool.length], assigneePool[(i + 6) % assigneePool.length]],
|
||||
quantity: (i % 5) + 2, // 고정값 (2~6)
|
||||
dueDate: '2025-01-15',
|
||||
priority: i + 1,
|
||||
status: 'inProgress',
|
||||
isUrgent: false,
|
||||
isDelayed: true,
|
||||
delayDays: item.delayDays,
|
||||
createdAt: '2025-12-20T09:00:00.000Z', // 고정값
|
||||
});
|
||||
});
|
||||
|
||||
// 일반 작업 (추가) - 대기/작업중 상태 위주로 추가
|
||||
for (let i = 0; i < 22; i++) {
|
||||
const process = processes[i % 3];
|
||||
// completed 비율을 줄이고 waiting/inProgress 위주로 생성
|
||||
const statusOptions: Array<'waiting' | 'inProgress' | 'completed'> = ['waiting', 'waiting', 'inProgress', 'inProgress', 'inProgress', 'completed'];
|
||||
orders.push({
|
||||
id: `work-${i + 1}`,
|
||||
orderNo: `KD-WO-${process === 'bending' ? 'FLD-' : ''}25${String(12).padStart(2, '0')}${String(i + 1).padStart(2, '0')}-${String((i % 3) + 1).padStart(2, '0')}`,
|
||||
productName: productNames[i % productNames.length],
|
||||
process,
|
||||
client: clients[i % clients.length],
|
||||
projectName: projects[i % projects.length],
|
||||
assignees: [assigneePool[i % assigneePool.length]],
|
||||
quantity: (i % 8) + 3, // 고정값 (3~10)
|
||||
dueDate: `2025-${String((i % 3) + 1).padStart(2, '0')}-${String((i % 28) + 1).padStart(2, '0')}`,
|
||||
priority: (i % 5) + 1,
|
||||
status: statusOptions[i % statusOptions.length],
|
||||
isUrgent: false,
|
||||
isDelayed: false,
|
||||
createdAt: '2025-12-20T09:00:00.000Z', // 고정값
|
||||
});
|
||||
}
|
||||
|
||||
// 작업자 화면용 추가 데이터 (대기/작업중 상태만)
|
||||
const additionalWaitingOrders = [
|
||||
{ orderNo: 'KD-WO-251201-01', process: 'screen' as ProcessType, client: '삼성물산(주)', project: '강남 타워 신축현장 (B동)', product: '스크린 서터 (표준형) - 추가', quantity: 3, priority: 1 },
|
||||
{ orderNo: 'KD-WO-251202-02', process: 'slat' as ProcessType, client: '현대건설(주)', project: '해운대 타워', product: '철재 슬랫 서터', quantity: 5, priority: 2 },
|
||||
{ orderNo: 'KD-WO-251203-03', process: 'screen' as ProcessType, client: '대림건설(주)', project: '대치 레이크파크', product: '스크린 서터 (대형)', quantity: 2, priority: 3 },
|
||||
{ orderNo: 'KD-WO-FLD-251204-01', process: 'bending' as ProcessType, client: '두산건설(주)', project: '위례 청라 센트럴파크', product: '방연셔터 절곡 부품', quantity: 8, priority: 1 },
|
||||
{ orderNo: 'KD-WO-251205-04', process: 'screen' as ProcessType, client: '(주)서울인테리어', project: '판교 물류센터', product: '스크린 서터 (표준형) - 추가', quantity: 4, priority: 2 },
|
||||
];
|
||||
|
||||
additionalWaitingOrders.forEach((item, i) => {
|
||||
orders.push({
|
||||
id: `additional-waiting-${i + 1}`,
|
||||
orderNo: item.orderNo,
|
||||
productName: item.product,
|
||||
process: item.process,
|
||||
client: item.client,
|
||||
projectName: item.project,
|
||||
assignees: [assigneePool[i % assigneePool.length]],
|
||||
quantity: item.quantity,
|
||||
dueDate: '2025-01-01',
|
||||
priority: item.priority,
|
||||
status: 'waiting',
|
||||
isUrgent: i === 0 || i === 3, // 1, 4번째 긴급
|
||||
isDelayed: false,
|
||||
instruction: i === 0 ? '추가분 주문, 기존 납품분과 동일 사양 유지' : undefined,
|
||||
createdAt: '2025-12-20T09:00:00.000Z',
|
||||
});
|
||||
});
|
||||
|
||||
return orders;
|
||||
};
|
||||
|
||||
// Mock 작업자 현황 데이터
|
||||
export const generateMockWorkerStatus = (): WorkerStatus[] => {
|
||||
return [
|
||||
{ id: 'w1', name: '김스크린', inProgress: 4, completed: 4, assigned: 9 },
|
||||
{ id: 'w2', name: '박스크린', inProgress: 4, completed: 4, assigned: 5 },
|
||||
{ id: 'w3', name: '김슬랫', inProgress: 0, completed: 3, assigned: 5 },
|
||||
{ id: 'w4', name: '박슬랫', inProgress: 0, completed: 2, assigned: 2 },
|
||||
{ id: 'w5', name: '이스크린', inProgress: 1, completed: 1, assigned: 2 },
|
||||
{ id: 'w6', name: '최절곡', inProgress: 0, completed: 2, assigned: 3 },
|
||||
{ id: 'w7', name: '이절곡', inProgress: 1, completed: 0, assigned: 1 },
|
||||
{ id: 'w8', name: '최스크린', inProgress: 0, completed: 1, assigned: 2 },
|
||||
];
|
||||
};
|
||||
79
src/components/production/ProductionDashboard/types.ts
Normal file
79
src/components/production/ProductionDashboard/types.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
// 작업 지시 상태
|
||||
export type WorkOrderStatus = 'waiting' | 'inProgress' | 'completed';
|
||||
|
||||
// 공정 타입
|
||||
export type ProcessType = 'screen' | 'slat' | 'bending' | 'all';
|
||||
|
||||
// 작업 지시
|
||||
export interface WorkOrder {
|
||||
id: string;
|
||||
orderNo: string; // KD-WO-251216-01
|
||||
productName: string; // 스크린 서터 (표준형) - 추가
|
||||
process: ProcessType; // 스크린, 슬랫, 절곡
|
||||
client: string; // 삼성물산(주)
|
||||
projectName: string; // 강남 타워 신축현장
|
||||
assignees: string[]; // 담당자 배열
|
||||
quantity: number; // EA 수량
|
||||
dueDate: string; // 납기
|
||||
priority: number; // 순위 (1~5)
|
||||
status: WorkOrderStatus;
|
||||
isUrgent: boolean;
|
||||
isDelayed: boolean;
|
||||
delayDays?: number; // 지연 일수
|
||||
instruction?: string; // 지시사항
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
// 작업자 현황
|
||||
export interface WorkerStatus {
|
||||
id: string;
|
||||
name: string;
|
||||
inProgress: number; // 작업중 건수
|
||||
completed: number; // 완료 건수
|
||||
assigned: number; // 배정 건수
|
||||
}
|
||||
|
||||
// 통계 데이터
|
||||
export interface DashboardStats {
|
||||
total: number; // 전체 작업
|
||||
waiting: number; // 작업 대기
|
||||
inProgress: number; // 작업중
|
||||
completed: number; // 작업 완료
|
||||
urgent: number; // 긴급
|
||||
delayed: number; // 지연
|
||||
}
|
||||
|
||||
// 탭 옵션
|
||||
export interface TabOption {
|
||||
value: ProcessType | 'all';
|
||||
label: string;
|
||||
}
|
||||
|
||||
export const TAB_OPTIONS: TabOption[] = [
|
||||
{ value: 'all', label: '전체' },
|
||||
{ value: 'screen', label: '스크린공장' },
|
||||
{ value: 'slat', label: '슬랫공장' },
|
||||
{ value: 'bending', label: '절곡공장' },
|
||||
];
|
||||
|
||||
// 공정 타입 라벨
|
||||
export const PROCESS_LABELS: Record<ProcessType, string> = {
|
||||
screen: '스크린',
|
||||
slat: '슬랫',
|
||||
bending: '절곡',
|
||||
all: '전체',
|
||||
};
|
||||
|
||||
// 상태 라벨
|
||||
export const STATUS_LABELS: Record<WorkOrderStatus, string> = {
|
||||
waiting: '대기',
|
||||
inProgress: '작업중',
|
||||
completed: '완료',
|
||||
};
|
||||
|
||||
// 상태 배지 색상
|
||||
export const STATUS_BADGE_COLORS: Record<WorkOrderStatus, string> = {
|
||||
waiting: 'bg-gray-100 text-gray-800',
|
||||
inProgress: 'bg-blue-100 text-blue-800',
|
||||
completed: 'bg-green-100 text-green-800',
|
||||
};
|
||||
250
src/components/production/WorkOrders/AssigneeSelectModal.tsx
Normal file
250
src/components/production/WorkOrders/AssigneeSelectModal.tsx
Normal file
@@ -0,0 +1,250 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 담당자 선택 모달
|
||||
*
|
||||
* 팀별로 그룹화된 담당자를 다중 선택할 수 있는 모달
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { Check, X } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { VisuallyHidden } from '@radix-ui/react-visually-hidden';
|
||||
|
||||
// 담당자 타입
|
||||
interface Assignee {
|
||||
id: string;
|
||||
name: string;
|
||||
teamId: string;
|
||||
}
|
||||
|
||||
// 팀 타입
|
||||
interface Team {
|
||||
id: string;
|
||||
name: string;
|
||||
members: Assignee[];
|
||||
}
|
||||
|
||||
// Mock 팀/담당자 데이터
|
||||
const TEAMS: Team[] = [
|
||||
{
|
||||
id: 'screen',
|
||||
name: '스크린팀',
|
||||
members: [
|
||||
{ id: 'a1', name: '김스크린', teamId: 'screen' },
|
||||
{ id: 'a2', name: '이스크린', teamId: 'screen' },
|
||||
{ id: 'a3', name: '박스크린', teamId: 'screen' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'bending',
|
||||
name: '절곡팀',
|
||||
members: [
|
||||
{ id: 'b1', name: '김절곡', teamId: 'bending' },
|
||||
{ id: 'b2', name: '이철곡', teamId: 'bending' },
|
||||
{ id: 'b3', name: '박철곡', teamId: 'bending' },
|
||||
{ id: 'b4', name: '최철곡', teamId: 'bending' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'slat',
|
||||
name: '슬랫팀',
|
||||
members: [
|
||||
{ id: 'c1', name: '김슬랫', teamId: 'slat' },
|
||||
{ id: 'c2', name: '이슬랫', teamId: 'slat' },
|
||||
{ id: 'c3', name: '박슬랫', teamId: 'slat' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'inventory',
|
||||
name: '재고(포장)팀',
|
||||
members: [
|
||||
{ id: 'd1', name: '김포팀', teamId: 'inventory' },
|
||||
{ id: 'd2', name: '이포팀', teamId: 'inventory' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
interface AssigneeSelectModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
selectedIds: string[];
|
||||
onSelect: (ids: string[], names: string[]) => void;
|
||||
}
|
||||
|
||||
export function AssigneeSelectModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
selectedIds,
|
||||
onSelect,
|
||||
}: AssigneeSelectModalProps) {
|
||||
const [localSelected, setLocalSelected] = useState<Set<string>>(new Set(selectedIds));
|
||||
|
||||
// 모달 열릴 때 선택 상태 초기화
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setLocalSelected(new Set(selectedIds));
|
||||
}
|
||||
}, [open, selectedIds]);
|
||||
|
||||
// 전체 담당자 맵
|
||||
const assigneeMap = useMemo(() => {
|
||||
const map = new Map<string, Assignee>();
|
||||
TEAMS.forEach((team) => {
|
||||
team.members.forEach((member) => {
|
||||
map.set(member.id, member);
|
||||
});
|
||||
});
|
||||
return map;
|
||||
}, []);
|
||||
|
||||
// 팀 전체 선택 여부 확인
|
||||
const isTeamFullySelected = (team: Team) => {
|
||||
return team.members.every((m) => localSelected.has(m.id));
|
||||
};
|
||||
|
||||
// 팀 부분 선택 여부 확인
|
||||
const isTeamPartiallySelected = (team: Team) => {
|
||||
const selectedCount = team.members.filter((m) => localSelected.has(m.id)).length;
|
||||
return selectedCount > 0 && selectedCount < team.members.length;
|
||||
};
|
||||
|
||||
// 개별 담당자 토글
|
||||
const toggleAssignee = (id: string) => {
|
||||
const newSelected = new Set(localSelected);
|
||||
if (newSelected.has(id)) {
|
||||
newSelected.delete(id);
|
||||
} else {
|
||||
newSelected.add(id);
|
||||
}
|
||||
setLocalSelected(newSelected);
|
||||
};
|
||||
|
||||
// 팀 전체 토글
|
||||
const toggleTeam = (team: Team) => {
|
||||
const newSelected = new Set(localSelected);
|
||||
const isFullySelected = isTeamFullySelected(team);
|
||||
|
||||
if (isFullySelected) {
|
||||
// 전체 선택 해제
|
||||
team.members.forEach((m) => newSelected.delete(m.id));
|
||||
} else {
|
||||
// 전체 선택
|
||||
team.members.forEach((m) => newSelected.add(m.id));
|
||||
}
|
||||
setLocalSelected(newSelected);
|
||||
};
|
||||
|
||||
// 선택 완료
|
||||
const handleConfirm = () => {
|
||||
const ids = Array.from(localSelected);
|
||||
const names = ids.map((id) => assigneeMap.get(id)?.name || '').filter(Boolean);
|
||||
onSelect(ids, names);
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md p-0 gap-0">
|
||||
<VisuallyHidden>
|
||||
<DialogTitle>담당자 선택</DialogTitle>
|
||||
</VisuallyHidden>
|
||||
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b">
|
||||
<h2 className="text-lg font-semibold">담당자 선택</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpenChange(false)}
|
||||
className="rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">닫기</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 설명 텍스트 */}
|
||||
<p className="text-sm text-muted-foreground px-6 py-3">
|
||||
팀 전체 또는 개별 작업자를 선택할 수 있습니다
|
||||
</p>
|
||||
|
||||
{/* 팀 목록 */}
|
||||
<div className="max-h-[400px] overflow-y-auto">
|
||||
{TEAMS.map((team) => (
|
||||
<div key={team.id}>
|
||||
{/* 팀 헤더 */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b bg-white">
|
||||
<div className="flex items-center gap-3">
|
||||
<Checkbox
|
||||
id={`team-${team.id}`}
|
||||
checked={isTeamFullySelected(team)}
|
||||
className="h-5 w-5 rounded border-2"
|
||||
data-state={
|
||||
isTeamPartiallySelected(team)
|
||||
? 'indeterminate'
|
||||
: isTeamFullySelected(team)
|
||||
? 'checked'
|
||||
: 'unchecked'
|
||||
}
|
||||
onCheckedChange={() => toggleTeam(team)}
|
||||
/>
|
||||
<label
|
||||
htmlFor={`team-${team.id}`}
|
||||
className="flex items-center gap-2 cursor-pointer"
|
||||
>
|
||||
<span className="text-muted-foreground">👥</span>
|
||||
<span className="font-semibold">{team.name}</span>
|
||||
<span className="text-muted-foreground">({team.members.length}명)</span>
|
||||
</label>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleTeam(team)}
|
||||
className="text-sm text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
팀 전체 선택
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 팀 멤버 */}
|
||||
{team.members.map((member) => (
|
||||
<div
|
||||
key={member.id}
|
||||
className="flex items-center gap-3 px-6 py-4 border-b ml-8"
|
||||
>
|
||||
<Checkbox
|
||||
id={`member-${member.id}`}
|
||||
checked={localSelected.has(member.id)}
|
||||
className="h-5 w-5 rounded border-2"
|
||||
onCheckedChange={() => toggleAssignee(member.id)}
|
||||
/>
|
||||
<label
|
||||
htmlFor={`member-${member.id}`}
|
||||
className="flex items-center gap-2 cursor-pointer"
|
||||
>
|
||||
<span className="text-muted-foreground">👤</span>
|
||||
<span>{member.name}</span>
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 하단 버튼 */}
|
||||
<div className="px-6 py-4">
|
||||
<Button onClick={handleConfirm} className="w-full bg-black hover:bg-black/90">
|
||||
<Check className="w-4 h-4 mr-1.5" />
|
||||
선택 완료 ({localSelected.size}명)
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
112
src/components/production/WorkOrders/SalesOrderSelectModal.tsx
Normal file
112
src/components/production/WorkOrders/SalesOrderSelectModal.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 수주 선택 모달
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Search, X, FileText } from 'lucide-react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { mockSalesOrders } from './mockData';
|
||||
import type { SalesOrder } from './types';
|
||||
|
||||
interface SalesOrderSelectModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSelect: (order: SalesOrder) => void;
|
||||
}
|
||||
|
||||
export function SalesOrderSelectModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
onSelect,
|
||||
}: SalesOrderSelectModalProps) {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
|
||||
// 필터링된 수주 목록
|
||||
const filteredOrders = mockSalesOrders.filter((order) => {
|
||||
if (!searchTerm) return true;
|
||||
const term = searchTerm.toLowerCase();
|
||||
return (
|
||||
order.orderNo.toLowerCase().includes(term) ||
|
||||
order.client.toLowerCase().includes(term) ||
|
||||
order.projectName.toLowerCase().includes(term)
|
||||
);
|
||||
});
|
||||
|
||||
const handleSelect = (order: SalesOrder) => {
|
||||
onSelect(order);
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>수주 선택</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{/* 검색 */}
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="수주번호, 거래처, 현장명 검색..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 안내 문구 */}
|
||||
<p className="text-sm text-muted-foreground">
|
||||
생산지시 가능한 수주 {filteredOrders.length}건 (회계확인 완료 상태)
|
||||
</p>
|
||||
|
||||
{/* 수주 목록 */}
|
||||
<div className="max-h-[400px] overflow-y-auto space-y-2">
|
||||
{filteredOrders.map((order) => (
|
||||
<div
|
||||
key={order.id}
|
||||
onClick={() => handleSelect(order)}
|
||||
className="p-4 border rounded-lg cursor-pointer hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-semibold">{order.orderNo}</span>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{order.status}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="text-sm text-right">
|
||||
<span className="text-muted-foreground">납기: </span>
|
||||
<span>{order.dueDate}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground mb-1">
|
||||
{order.client}
|
||||
</div>
|
||||
<div className="text-sm mb-2">{order.projectName}</div>
|
||||
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
||||
<span>{order.itemCount}개 품목</span>
|
||||
<span>분할 {order.splitCount}건</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{filteredOrders.length === 0 && (
|
||||
<div className="py-8 text-center text-muted-foreground">
|
||||
<FileText className="w-8 h-8 mx-auto mb-2 opacity-50" />
|
||||
<p>검색 결과가 없습니다.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
469
src/components/production/WorkOrders/WorkOrderCreate.tsx
Normal file
469
src/components/production/WorkOrders/WorkOrderCreate.tsx
Normal file
@@ -0,0 +1,469 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 작업지시 등록 페이지
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { ArrowLeft, FileText, X, Edit2 } 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 { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { PageLayout } from '@/components/organisms/PageLayout';
|
||||
import { SalesOrderSelectModal } from './SalesOrderSelectModal';
|
||||
import { AssigneeSelectModal } from './AssigneeSelectModal';
|
||||
import { PROCESS_TYPE_LABELS, type ProcessType, type SalesOrder } from './types';
|
||||
|
||||
// Validation 에러 타입
|
||||
interface ValidationErrors {
|
||||
[key: string]: string;
|
||||
}
|
||||
|
||||
// 필드명 매핑
|
||||
const FIELD_NAME_MAP: Record<string, string> = {
|
||||
selectedOrder: '수주',
|
||||
client: '발주처',
|
||||
projectName: '현장명',
|
||||
shipmentDate: '출고예정일',
|
||||
};
|
||||
|
||||
type RegistrationMode = 'linked' | 'manual';
|
||||
|
||||
interface FormData {
|
||||
// 수주 정보
|
||||
selectedOrder: SalesOrder | null;
|
||||
splitOption: 'all' | 'partial';
|
||||
|
||||
// 기본 정보
|
||||
client: string;
|
||||
projectName: string;
|
||||
orderNo: string;
|
||||
itemCount: number;
|
||||
|
||||
// 작업지시 정보
|
||||
processType: ProcessType;
|
||||
shipmentDate: string;
|
||||
priority: number;
|
||||
assignees: string[];
|
||||
|
||||
// 비고
|
||||
note: string;
|
||||
}
|
||||
|
||||
const initialFormData: FormData = {
|
||||
selectedOrder: null,
|
||||
splitOption: 'all',
|
||||
client: '',
|
||||
projectName: '',
|
||||
orderNo: '',
|
||||
itemCount: 0,
|
||||
processType: 'screen',
|
||||
shipmentDate: '',
|
||||
priority: 5,
|
||||
assignees: [],
|
||||
note: '',
|
||||
};
|
||||
|
||||
export function WorkOrderCreate() {
|
||||
const router = useRouter();
|
||||
const [mode, setMode] = useState<RegistrationMode>('linked');
|
||||
const [formData, setFormData] = useState<FormData>(initialFormData);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [isAssigneeModalOpen, setIsAssigneeModalOpen] = useState(false);
|
||||
const [assigneeNames, setAssigneeNames] = useState<string[]>([]);
|
||||
const [validationErrors, setValidationErrors] = useState<ValidationErrors>({});
|
||||
|
||||
// 수주 선택 핸들러
|
||||
const handleSelectOrder = (order: SalesOrder) => {
|
||||
setFormData({
|
||||
...formData,
|
||||
selectedOrder: order,
|
||||
client: order.client,
|
||||
projectName: order.projectName,
|
||||
orderNo: order.orderNo,
|
||||
itemCount: order.itemCount,
|
||||
});
|
||||
};
|
||||
|
||||
// 수주 해제
|
||||
const handleClearOrder = () => {
|
||||
setFormData({
|
||||
...initialFormData,
|
||||
processType: formData.processType,
|
||||
shipmentDate: formData.shipmentDate,
|
||||
priority: formData.priority,
|
||||
});
|
||||
};
|
||||
|
||||
// 폼 제출
|
||||
const handleSubmit = () => {
|
||||
// Validation 체크
|
||||
const errors: ValidationErrors = {};
|
||||
|
||||
if (mode === 'linked') {
|
||||
if (!formData.selectedOrder) {
|
||||
errors.selectedOrder = '수주를 선택해주세요';
|
||||
}
|
||||
} else {
|
||||
if (!formData.client) {
|
||||
errors.client = '발주처를 입력해주세요';
|
||||
}
|
||||
if (!formData.projectName) {
|
||||
errors.projectName = '현장명을 입력해주세요';
|
||||
}
|
||||
}
|
||||
|
||||
if (!formData.shipmentDate) {
|
||||
errors.shipmentDate = '출고예정일을 선택해주세요';
|
||||
}
|
||||
|
||||
// 에러가 있으면 상태 업데이트 후 리턴
|
||||
if (Object.keys(errors).length > 0) {
|
||||
setValidationErrors(errors);
|
||||
// 페이지 상단으로 스크롤
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
return;
|
||||
}
|
||||
|
||||
// 에러 초기화
|
||||
setValidationErrors({});
|
||||
|
||||
// TODO: API 호출
|
||||
console.log('Submit:', formData);
|
||||
router.push('/production/work-orders');
|
||||
};
|
||||
|
||||
// 취소
|
||||
const handleCancel = () => {
|
||||
router.back();
|
||||
};
|
||||
|
||||
// 공정 코드 표시
|
||||
const getProcessCode = (type: ProcessType) => {
|
||||
const codes: Record<ProcessType, string> = {
|
||||
screen: 'P-001 | 작업일지: WL-SCR',
|
||||
slat: 'P-002 | 작업일지: WL-SLT',
|
||||
bending: 'P-003 | 작업일지: WL-FLD',
|
||||
};
|
||||
return codes[type];
|
||||
};
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="ghost" size="icon" onClick={handleCancel}>
|
||||
<ArrowLeft className="w-5 h-5" />
|
||||
</Button>
|
||||
<h1 className="text-2xl font-bold flex items-center gap-2">
|
||||
<FileText className="w-5 h-5" />
|
||||
작업지시 등록
|
||||
</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" onClick={handleCancel}>
|
||||
취소
|
||||
</Button>
|
||||
<Button onClick={handleSubmit}>
|
||||
등록
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Validation 에러 표시 */}
|
||||
{Object.keys(validationErrors).length > 0 && (
|
||||
<Alert className="bg-red-50 border-red-200">
|
||||
<AlertDescription className="text-red-900">
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="text-lg">⚠️</span>
|
||||
<div className="flex-1">
|
||||
<strong className="block mb-2">
|
||||
입력 내용을 확인해주세요 ({Object.keys(validationErrors).length}개 오류)
|
||||
</strong>
|
||||
<ul className="space-y-1 text-sm">
|
||||
{Object.entries(validationErrors).map(([field, message]) => {
|
||||
const fieldName = FIELD_NAME_MAP[field] || field;
|
||||
return (
|
||||
<li key={field} className="flex items-start gap-1">
|
||||
<span>•</span>
|
||||
<span>
|
||||
<strong>{fieldName}</strong>: {message}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* 등록 방식 */}
|
||||
<section className="bg-white border rounded-lg p-6">
|
||||
<h3 className="font-semibold mb-4">등록 방식</h3>
|
||||
<RadioGroup
|
||||
value={mode}
|
||||
onValueChange={(value) => setMode(value as RegistrationMode)}
|
||||
className="flex gap-6"
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="linked" id="linked" />
|
||||
<Label htmlFor="linked" className="cursor-pointer">
|
||||
수주 연동 등록{' '}
|
||||
<span className="text-muted-foreground text-sm">
|
||||
(회계확인 완료된 수주에서 불러오기)
|
||||
</span>
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="manual" id="manual" />
|
||||
<Label htmlFor="manual" className="cursor-pointer">
|
||||
수동 등록{' '}
|
||||
<span className="text-muted-foreground text-sm">
|
||||
(수주 없이 직접 입력)
|
||||
</span>
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</section>
|
||||
|
||||
{/* 수주 정보 (연동 모드) */}
|
||||
{mode === 'linked' && (
|
||||
<section className="bg-muted/30 border rounded-lg p-6">
|
||||
<h3 className="font-semibold mb-4">수주 정보</h3>
|
||||
{!formData.selectedOrder ? (
|
||||
<div className="flex items-center justify-between p-4 bg-white border rounded-lg">
|
||||
<div className="flex items-center gap-3">
|
||||
<FileText className="w-5 h-5 text-muted-foreground" />
|
||||
<div>
|
||||
<p className="font-medium">수주에서 불러오기</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
회계확인 완료된 수주를 선택하면 정보가 자동으로 채워집니다
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="outline" onClick={() => setIsModalOpen(true)}>
|
||||
<FileText className="w-4 h-4 mr-1.5" />
|
||||
수주 선택
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-4 bg-white border rounded-lg">
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="font-semibold">{formData.selectedOrder.orderNo}</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{formData.selectedOrder.status}에서 불러옴
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{formData.selectedOrder.client} / {formData.selectedOrder.projectName} / {formData.selectedOrder.itemCount}개 품목
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="ghost" size="sm" onClick={handleClearOrder}>
|
||||
<X className="w-4 h-4 mr-1" />
|
||||
해제
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => setIsModalOpen(true)}>
|
||||
<Edit2 className="w-4 h-4 mr-1" />
|
||||
변경
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 분할 선택 */}
|
||||
<div className="pt-4 border-t">
|
||||
<h4 className="text-sm font-medium mb-2">분할 선택</h4>
|
||||
<RadioGroup
|
||||
value={formData.splitOption}
|
||||
onValueChange={(value) =>
|
||||
setFormData({ ...formData, splitOption: value as 'all' | 'partial' })
|
||||
}
|
||||
className="flex gap-4"
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="all" id="split-all" />
|
||||
<Label htmlFor="split-all" className="cursor-pointer">
|
||||
전체 품목
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="partial" id="split-partial" />
|
||||
<Label htmlFor="split-partial" className="cursor-pointer">
|
||||
{formData.selectedOrder.orderNo}-01
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* 기본 정보 */}
|
||||
<section className="bg-muted/30 border rounded-lg p-6">
|
||||
<h3 className="font-semibold mb-4">기본 정보</h3>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>발주처 *</Label>
|
||||
<Input
|
||||
value={formData.client}
|
||||
onChange={(e) => setFormData({ ...formData, client: e.target.value })}
|
||||
placeholder={mode === 'linked' ? '수주를 선택하면 자동 입력됩니다' : '발주처 입력'}
|
||||
disabled={mode === 'linked'}
|
||||
className="bg-white"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>현장명 *</Label>
|
||||
<Input
|
||||
value={formData.projectName}
|
||||
onChange={(e) => setFormData({ ...formData, projectName: e.target.value })}
|
||||
placeholder={mode === 'linked' ? '수주를 선택하면 자동 입력됩니다' : '현장명 입력'}
|
||||
disabled={mode === 'linked'}
|
||||
className="bg-white"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>수주번호</Label>
|
||||
<Input
|
||||
value={formData.orderNo}
|
||||
onChange={(e) => setFormData({ ...formData, orderNo: e.target.value })}
|
||||
placeholder={mode === 'linked' ? '수주를 선택하면 자동 입력됩니다' : '수주번호 입력'}
|
||||
disabled={mode === 'linked'}
|
||||
className="bg-white"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>품목수</Label>
|
||||
<Input
|
||||
value={formData.itemCount || ''}
|
||||
onChange={(e) => setFormData({ ...formData, itemCount: parseInt(e.target.value) || 0 })}
|
||||
placeholder={mode === 'linked' ? '수주를 선택하면 자동 입력됩니다' : '품목수 입력'}
|
||||
disabled={mode === 'linked'}
|
||||
className="bg-white"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 작업지시 정보 */}
|
||||
<section className="bg-muted/30 border rounded-lg p-6">
|
||||
<h3 className="font-semibold mb-4">작업지시 정보</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>공정구분 *</Label>
|
||||
<Select
|
||||
value={formData.processType}
|
||||
onValueChange={(value) => setFormData({ ...formData, processType: value as ProcessType })}
|
||||
>
|
||||
<SelectTrigger className="bg-white">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(PROCESS_TYPE_LABELS).map(([key, label]) => (
|
||||
<SelectItem key={key} value={key}>
|
||||
{label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
공정코드: {getProcessCode(formData.processType)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>출고예정일 *</Label>
|
||||
<Input
|
||||
type="date"
|
||||
value={formData.shipmentDate}
|
||||
onChange={(e) => setFormData({ ...formData, shipmentDate: e.target.value })}
|
||||
className="bg-white"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>우선순위 (1=긴급, 9=낮음)</Label>
|
||||
<Select
|
||||
value={formData.priority.toString()}
|
||||
onValueChange={(value) => setFormData({ ...formData, priority: parseInt(value) })}
|
||||
>
|
||||
<SelectTrigger className="bg-white">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{[1, 2, 3, 4, 5, 6, 7, 8, 9].map((n) => (
|
||||
<SelectItem key={n} value={n.toString()}>
|
||||
{n} {n === 5 ? '(일반)' : n === 1 ? '(긴급)' : ''}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>담당자 (다중선택 가능)</Label>
|
||||
<div
|
||||
onClick={() => setIsAssigneeModalOpen(true)}
|
||||
className="flex min-h-10 w-full cursor-pointer items-center rounded-md border border-input bg-white px-3 py-2 text-sm ring-offset-background hover:bg-accent/50"
|
||||
>
|
||||
{assigneeNames.length > 0 ? (
|
||||
<span>{assigneeNames.join(', ')}</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">담당자를 선택하세요 (팀/개인)</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 비고 */}
|
||||
<section className="bg-muted/30 border rounded-lg p-6">
|
||||
<h3 className="font-semibold mb-4">비고</h3>
|
||||
<Textarea
|
||||
value={formData.note}
|
||||
onChange={(e) => setFormData({ ...formData, note: e.target.value })}
|
||||
placeholder="특이사항이나 메모를 입력하세요"
|
||||
rows={4}
|
||||
className="bg-white"
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/* 수주 선택 모달 */}
|
||||
<SalesOrderSelectModal
|
||||
open={isModalOpen}
|
||||
onOpenChange={setIsModalOpen}
|
||||
onSelect={handleSelectOrder}
|
||||
/>
|
||||
|
||||
{/* 담당자 선택 모달 */}
|
||||
<AssigneeSelectModal
|
||||
open={isAssigneeModalOpen}
|
||||
onOpenChange={setIsAssigneeModalOpen}
|
||||
selectedIds={formData.assignees}
|
||||
onSelect={(ids, names) => {
|
||||
setFormData({ ...formData, assignees: ids });
|
||||
setAssigneeNames(names);
|
||||
}}
|
||||
/>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
355
src/components/production/WorkOrders/WorkOrderDetail.tsx
Normal file
355
src/components/production/WorkOrders/WorkOrderDetail.tsx
Normal file
@@ -0,0 +1,355 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 작업지시 상세 페이지
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { FileText, List, AlertTriangle, Play, CheckCircle2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { PageLayout } from '@/components/organisms/PageLayout';
|
||||
import { WorkLogModal } from '../WorkerScreen/WorkLogModal';
|
||||
import { mockWorkOrders } from './mockData';
|
||||
import {
|
||||
PROCESS_TYPE_LABELS,
|
||||
WORK_ORDER_STATUS_LABELS,
|
||||
WORK_ORDER_STATUS_COLORS,
|
||||
ITEM_STATUS_LABELS,
|
||||
ISSUE_STATUS_LABELS,
|
||||
SCREEN_PROCESS_STEPS,
|
||||
SLAT_PROCESS_STEPS,
|
||||
BENDING_PROCESS_STEPS,
|
||||
type WorkOrder,
|
||||
type ProcessType,
|
||||
} from './types';
|
||||
|
||||
// 공정 진행 단계 컴포넌트
|
||||
function ProcessSteps({
|
||||
processType,
|
||||
currentStep,
|
||||
}: {
|
||||
processType: ProcessType;
|
||||
currentStep: number;
|
||||
}) {
|
||||
const steps =
|
||||
processType === 'screen'
|
||||
? SCREEN_PROCESS_STEPS
|
||||
: processType === 'slat'
|
||||
? SLAT_PROCESS_STEPS
|
||||
: BENDING_PROCESS_STEPS;
|
||||
|
||||
return (
|
||||
<div className="bg-white border rounded-lg p-6">
|
||||
<h3 className="font-semibold mb-4">공정 진행 ({steps.length}단계)</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
{steps.map((step, index) => {
|
||||
const isCompleted = index < currentStep;
|
||||
const isCurrent = index === currentStep;
|
||||
|
||||
return (
|
||||
<div key={step.key} className="flex items-center">
|
||||
<div
|
||||
className={`flex items-center gap-2 px-4 py-2 rounded-full border ${
|
||||
isCompleted
|
||||
? 'bg-gray-900 text-white border-gray-900'
|
||||
: isCurrent
|
||||
? 'bg-white border-gray-900 text-gray-900'
|
||||
: 'bg-white border-gray-300 text-gray-500'
|
||||
}`}
|
||||
>
|
||||
<span className="font-medium">{step.order}</span>
|
||||
<span>{step.label}</span>
|
||||
{isCompleted && (
|
||||
<span className="text-xs bg-white text-gray-900 px-1.5 py-0.5 rounded">
|
||||
완료
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 전개도 상세정보 컴포넌트 (절곡용)
|
||||
function BendingDetailsSection({ order }: { order: WorkOrder }) {
|
||||
if (!order.bendingDetails || order.bendingDetails.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white border rounded-lg p-6">
|
||||
<h3 className="font-semibold mb-4">전개도 상세정보</h3>
|
||||
<div className="space-y-4">
|
||||
{order.bendingDetails.map((detail) => (
|
||||
<div key={detail.id} className="border rounded-lg overflow-hidden">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between bg-gray-100 px-4 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-semibold">{detail.code}</span>
|
||||
<span className="font-medium">{detail.name}</span>
|
||||
<span className="text-sm text-muted-foreground">{detail.material}</span>
|
||||
</div>
|
||||
<span className="text-sm">수량: {detail.quantity}</span>
|
||||
</div>
|
||||
|
||||
{/* 상세 정보 */}
|
||||
<div className="grid grid-cols-5 gap-4 p-4">
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground mb-1">전개폭</p>
|
||||
<p className="font-medium">{detail.developWidth}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground mb-1">길이</p>
|
||||
<p className="font-medium">{detail.length}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground mb-1">중량</p>
|
||||
<p className="font-medium">{detail.weight}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground mb-1">비고</p>
|
||||
<p className="font-medium">{detail.note}</p>
|
||||
</div>
|
||||
<div className="row-span-2 flex items-center justify-center border rounded bg-gray-50">
|
||||
{/* 전개도 이미지 placeholder */}
|
||||
<div className="text-center p-4">
|
||||
<div className="w-24 h-16 border-2 border-dashed border-gray-300 rounded flex items-center justify-center mb-1">
|
||||
<span className="text-xs text-muted-foreground">전개도</span>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">{detail.developDimension}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-4">
|
||||
<p className="text-xs text-muted-foreground mb-1">전개치수</p>
|
||||
<p className="font-medium">{detail.developDimension}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 이슈 섹션 컴포넌트
|
||||
function IssueSection({ order }: { order: WorkOrder }) {
|
||||
if (!order.issues || order.issues.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white border rounded-lg p-6">
|
||||
<h3 className="font-semibold mb-4">이슈 ({order.issues.length}건)</h3>
|
||||
<div className="space-y-3">
|
||||
{order.issues.map((issue) => (
|
||||
<div key={issue.id} className="flex items-start gap-3 p-4 bg-red-50 border border-red-200 rounded-lg">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`shrink-0 ${
|
||||
issue.status === 'processing'
|
||||
? 'bg-yellow-100 text-yellow-700 border-yellow-300'
|
||||
: issue.status === 'resolved'
|
||||
? 'bg-green-100 text-green-700 border-green-300'
|
||||
: 'bg-gray-100 text-gray-700 border-gray-300'
|
||||
}`}
|
||||
>
|
||||
{ISSUE_STATUS_LABELS[issue.status]}
|
||||
</Badge>
|
||||
<div>
|
||||
<span className="font-medium">{issue.type}</span>
|
||||
<span className="mx-2 text-muted-foreground">·</span>
|
||||
<span>{issue.description}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface WorkOrderDetailProps {
|
||||
orderId: string;
|
||||
}
|
||||
|
||||
export function WorkOrderDetail({ orderId }: WorkOrderDetailProps) {
|
||||
const router = useRouter();
|
||||
const [isWorkLogOpen, setIsWorkLogOpen] = useState(false);
|
||||
|
||||
// orderId는 workOrderNo로 전달됨 (예: KD-WO-251217-12)
|
||||
// URL 디코딩 후 검색 (encodeURIComponent로 인코딩되어 있을 수 있음)
|
||||
const decodedOrderId = decodeURIComponent(orderId);
|
||||
const order = mockWorkOrders.find(
|
||||
(o) => o.id === decodedOrderId || o.workOrderNo === decodedOrderId
|
||||
);
|
||||
|
||||
if (!order) {
|
||||
return (
|
||||
<PageLayout>
|
||||
<h1 className="text-2xl font-bold mb-6">작업지시 상세</h1>
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
작업지시를 찾을 수 없습니다.
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
// 작업일지용 WorkOrder 변환 (기존 WorkLogModal 타입에 맞춤)
|
||||
const workLogOrder = {
|
||||
id: order.id,
|
||||
orderNo: order.workOrderNo,
|
||||
productName: order.items[0]?.productName || '-',
|
||||
client: order.client,
|
||||
projectName: order.projectName,
|
||||
dueDate: order.dueDate,
|
||||
quantity: order.items.reduce((sum, item) => sum + item.quantity, 0),
|
||||
progress: order.currentStep * 20, // 대략적인 진행률
|
||||
process: order.processType as 'screen' | 'slat' | 'bending',
|
||||
assignees: [order.assignee],
|
||||
instruction: order.note || '',
|
||||
status: 'in_progress' as const,
|
||||
priority: order.priority <= 3 ? 'high' : order.priority <= 6 ? 'medium' : 'low',
|
||||
};
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1 className="text-2xl font-bold">작업지시 상세</h1>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" onClick={() => setIsWorkLogOpen(true)}>
|
||||
<FileText className="w-4 h-4 mr-1.5" />
|
||||
작업일지
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => router.push('/production/work-orders')}>
|
||||
<List className="w-4 h-4 mr-1.5" />
|
||||
목록
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* 기본 정보 */}
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-lg p-6">
|
||||
<h3 className="font-semibold mb-4">기본 정보</h3>
|
||||
<div className="grid grid-cols-4 gap-y-4">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground mb-1">작업지시번호</p>
|
||||
<p className="font-medium">{order.workOrderNo}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground mb-1">로트번호</p>
|
||||
<p className="font-medium">{order.lotNo}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground mb-1">공정구분</p>
|
||||
<p className="font-medium">{PROCESS_TYPE_LABELS[order.processType]}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground mb-1">작업상태</p>
|
||||
<Badge className={`${WORK_ORDER_STATUS_COLORS[order.status]} border-0`}>
|
||||
{WORK_ORDER_STATUS_LABELS[order.status]}
|
||||
</Badge>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground mb-1">발주처</p>
|
||||
<p className="font-medium">{order.client}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground mb-1">현장명</p>
|
||||
<p className="font-medium">{order.projectName}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground mb-1">납기일</p>
|
||||
<p className="font-medium">{order.dueDate}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground mb-1">작업자</p>
|
||||
<p className="font-medium">{order.assignee}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 공정 진행 */}
|
||||
<ProcessSteps processType={order.processType} currentStep={order.currentStep} />
|
||||
|
||||
{/* 작업 품목 */}
|
||||
<div className="bg-white border rounded-lg p-6">
|
||||
<h3 className="font-semibold mb-4">작업 품목 ({order.items.length}건)</h3>
|
||||
{order.items.length > 0 ? (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-muted/50">
|
||||
<TableHead className="w-14">No</TableHead>
|
||||
<TableHead className="w-20">상태</TableHead>
|
||||
<TableHead>품목명</TableHead>
|
||||
<TableHead className="w-28">층/부호</TableHead>
|
||||
<TableHead className="w-32">규격</TableHead>
|
||||
<TableHead className="w-20 text-right">수량</TableHead>
|
||||
<TableHead className="w-20">작업</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{order.items.map((item) => (
|
||||
<TableRow key={item.id}>
|
||||
<TableCell>{item.no}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline">{ITEM_STATUS_LABELS[item.status]}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="font-medium">{item.productName}</TableCell>
|
||||
<TableCell>{item.floorCode}</TableCell>
|
||||
<TableCell>{item.specification}</TableCell>
|
||||
<TableCell className="text-right">{item.quantity}</TableCell>
|
||||
<TableCell>
|
||||
{item.status === 'waiting' && (
|
||||
<Button variant="outline" size="sm">
|
||||
<Play className="w-3 h-3 mr-1" />
|
||||
시작
|
||||
</Button>
|
||||
)}
|
||||
{item.status === 'in_progress' && (
|
||||
<Button variant="outline" size="sm">
|
||||
<CheckCircle2 className="w-3 h-3 mr-1" />
|
||||
완료
|
||||
</Button>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
) : (
|
||||
<p className="text-muted-foreground text-center py-8">
|
||||
등록된 품목이 없습니다.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 절곡 전용: 전개도 상세정보 */}
|
||||
{order.processType === 'bending' && <BendingDetailsSection order={order} />}
|
||||
|
||||
{/* 이슈 섹션 */}
|
||||
<IssueSection order={order} />
|
||||
</div>
|
||||
|
||||
{/* 작업일지 모달 */}
|
||||
<WorkLogModal
|
||||
open={isWorkLogOpen}
|
||||
onOpenChange={setIsWorkLogOpen}
|
||||
order={workLogOrder}
|
||||
/>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
310
src/components/production/WorkOrders/WorkOrderList.tsx
Normal file
310
src/components/production/WorkOrders/WorkOrderList.tsx
Normal file
@@ -0,0 +1,310 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 작업지시 목록 페이지
|
||||
* IntegratedListTemplateV2 패턴 적용
|
||||
*/
|
||||
|
||||
import { useState, useMemo, useCallback } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Plus, FileText, Calendar, Users, CheckCircle2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { TableCell, TableRow } from '@/components/ui/table';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import {
|
||||
IntegratedListTemplateV2,
|
||||
TabOption,
|
||||
TableColumn,
|
||||
StatCard,
|
||||
} from '@/components/templates/IntegratedListTemplateV2';
|
||||
import { ListMobileCard, InfoField } from '@/components/organisms/ListMobileCard';
|
||||
import { mockWorkOrders, mockStats } from './mockData';
|
||||
import {
|
||||
PROCESS_TYPE_LABELS,
|
||||
WORK_ORDER_STATUS_LABELS,
|
||||
WORK_ORDER_STATUS_COLORS,
|
||||
type WorkOrder,
|
||||
} from './types';
|
||||
|
||||
// 탭 필터 정의
|
||||
type TabFilter = 'all' | 'unassigned' | 'pending' | 'waiting' | 'in_progress' | 'completed';
|
||||
|
||||
// 페이지당 항목 수
|
||||
const ITEMS_PER_PAGE = 20;
|
||||
|
||||
export function WorkOrderList() {
|
||||
const router = useRouter();
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [activeTab, setActiveTab] = useState<TabFilter>('all');
|
||||
const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set());
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
|
||||
// 탭별 카운트 계산
|
||||
const tabCounts = useMemo(() => {
|
||||
return {
|
||||
all: mockWorkOrders.length,
|
||||
unassigned: mockWorkOrders.filter((o) => o.status === 'unassigned').length,
|
||||
pending: mockWorkOrders.filter((o) => o.status === 'pending').length,
|
||||
waiting: mockWorkOrders.filter((o) => o.status === 'waiting').length,
|
||||
in_progress: mockWorkOrders.filter((o) => o.status === 'in_progress').length,
|
||||
completed: mockWorkOrders.filter((o) => o.status === 'completed' || o.status === 'shipped').length,
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 탭 옵션
|
||||
const tabs: TabOption[] = [
|
||||
{ value: 'all', label: '전체', count: tabCounts.all },
|
||||
{ value: 'unassigned', label: '미배정', count: tabCounts.unassigned, color: 'gray' },
|
||||
{ value: 'pending', label: '승인대기', count: tabCounts.pending, color: 'orange' },
|
||||
{ value: 'waiting', label: '작업대기', count: tabCounts.waiting, color: 'yellow' },
|
||||
{ value: 'in_progress', label: '작업중', count: tabCounts.in_progress, color: 'blue' },
|
||||
{ value: 'completed', label: '작업완료', count: tabCounts.completed, color: 'green' },
|
||||
];
|
||||
|
||||
// 통계 카드
|
||||
const stats: StatCard[] = [
|
||||
{
|
||||
label: '전체',
|
||||
value: mockStats.total,
|
||||
icon: FileText,
|
||||
iconColor: 'text-gray-600',
|
||||
},
|
||||
{
|
||||
label: '작업대기',
|
||||
value: mockStats.waiting + mockStats.unassigned + mockStats.pending,
|
||||
icon: Calendar,
|
||||
iconColor: 'text-orange-600',
|
||||
},
|
||||
{
|
||||
label: '작업중',
|
||||
value: mockStats.inProgress,
|
||||
icon: Users,
|
||||
iconColor: 'text-blue-600',
|
||||
},
|
||||
{
|
||||
label: '작업완료',
|
||||
value: mockStats.completed,
|
||||
icon: CheckCircle2,
|
||||
iconColor: 'text-green-600',
|
||||
},
|
||||
];
|
||||
|
||||
// 테이블 컬럼
|
||||
const tableColumns: TableColumn[] = [
|
||||
{ key: 'no', label: '번호', className: 'w-[60px] text-center' },
|
||||
{ key: 'workOrderNo', label: '작업지시번호', className: 'min-w-[140px]' },
|
||||
{ key: 'processType', label: '공정', className: 'w-[80px]' },
|
||||
{ key: 'lotNo', label: '로트번호', className: 'min-w-[120px]' },
|
||||
{ key: 'orderDate', label: '지시일', className: 'w-[100px]' },
|
||||
{ key: 'isAssigned', label: '배정', className: 'w-[60px] text-center' },
|
||||
{ key: 'hasWork', label: '작업', className: 'w-[60px] text-center' },
|
||||
{ key: 'isStarted', label: '시작', className: 'w-[60px] text-center' },
|
||||
{ key: 'status', label: '작업상태', className: 'w-[100px]' },
|
||||
{ key: 'priority', label: '현장순위', className: 'w-[80px] text-center' },
|
||||
{ key: 'assignee', label: '작업자', className: 'w-[80px]' },
|
||||
{ key: 'projectName', label: '현장명', className: 'min-w-[150px]' },
|
||||
{ key: 'shipmentDate', label: '출고예정일', className: 'w-[110px]' },
|
||||
];
|
||||
|
||||
// 필터링된 데이터
|
||||
const filteredOrders = useMemo(() => {
|
||||
let result = [...mockWorkOrders];
|
||||
|
||||
// 탭 필터
|
||||
if (activeTab !== 'all') {
|
||||
if (activeTab === 'completed') {
|
||||
result = result.filter((o) => o.status === 'completed' || o.status === 'shipped');
|
||||
} else {
|
||||
result = result.filter((o) => o.status === activeTab);
|
||||
}
|
||||
}
|
||||
|
||||
// 검색 필터
|
||||
if (searchTerm) {
|
||||
const term = searchTerm.toLowerCase();
|
||||
result = result.filter(
|
||||
(o) =>
|
||||
o.workOrderNo.toLowerCase().includes(term) ||
|
||||
o.client.toLowerCase().includes(term) ||
|
||||
o.projectName.toLowerCase().includes(term)
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [activeTab, searchTerm]);
|
||||
|
||||
// 페이지네이션 데이터
|
||||
const paginatedData = useMemo(() => {
|
||||
const startIndex = (currentPage - 1) * ITEMS_PER_PAGE;
|
||||
return filteredOrders.slice(startIndex, startIndex + ITEMS_PER_PAGE);
|
||||
}, [filteredOrders, currentPage]);
|
||||
|
||||
// 페이지네이션 설정
|
||||
const pagination = {
|
||||
currentPage,
|
||||
totalPages: Math.ceil(filteredOrders.length / ITEMS_PER_PAGE),
|
||||
totalItems: filteredOrders.length,
|
||||
itemsPerPage: ITEMS_PER_PAGE,
|
||||
onPageChange: setCurrentPage,
|
||||
};
|
||||
|
||||
// 선택 핸들러
|
||||
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((o) => o.id)));
|
||||
}
|
||||
}, [paginatedData, selectedItems.size]);
|
||||
|
||||
// 상세 페이지 이동
|
||||
const handleView = useCallback((id: string) => {
|
||||
router.push(`/production/work-orders/${id}`);
|
||||
}, [router]);
|
||||
|
||||
// 등록 페이지 이동
|
||||
const handleCreate = () => {
|
||||
router.push('/production/work-orders/create');
|
||||
};
|
||||
|
||||
// 탭 변경 시 페이지 리셋
|
||||
const handleTabChange = (value: string) => {
|
||||
setActiveTab(value as TabFilter);
|
||||
setCurrentPage(1);
|
||||
setSelectedItems(new Set());
|
||||
};
|
||||
|
||||
// 검색 변경 시 페이지 리셋
|
||||
const handleSearchChange = (value: string) => {
|
||||
setSearchTerm(value);
|
||||
setCurrentPage(1);
|
||||
};
|
||||
|
||||
// 테이블 행 렌더링
|
||||
const renderTableRow = (order: WorkOrder, index: number, globalIndex: number) => {
|
||||
const isSelected = selectedItems.has(order.id);
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
key={order.id}
|
||||
className={`cursor-pointer hover:bg-muted/50 ${isSelected ? 'bg-blue-50' : ''}`}
|
||||
onClick={() => handleView(order.id)}
|
||||
>
|
||||
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={() => handleToggleSelection(order.id)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-center text-muted-foreground">{globalIndex}</TableCell>
|
||||
<TableCell className="font-medium">{order.workOrderNo}</TableCell>
|
||||
<TableCell>{PROCESS_TYPE_LABELS[order.processType]}</TableCell>
|
||||
<TableCell>{order.lotNo}</TableCell>
|
||||
<TableCell>{order.orderDate}</TableCell>
|
||||
<TableCell className="text-center">{order.isAssigned ? 'Y' : '-'}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{order.status !== 'unassigned' && order.status !== 'pending' ? 'Y' : '-'}
|
||||
</TableCell>
|
||||
<TableCell className="text-center">{order.isStarted ? 'Y' : '-'}</TableCell>
|
||||
<TableCell>
|
||||
<Badge className={`${WORK_ORDER_STATUS_COLORS[order.status]} border-0`}>
|
||||
{WORK_ORDER_STATUS_LABELS[order.status]}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">{order.priority}</TableCell>
|
||||
<TableCell>{order.assignee}</TableCell>
|
||||
<TableCell className="max-w-[200px] truncate">{order.projectName}</TableCell>
|
||||
<TableCell>{order.shipmentDate}</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
};
|
||||
|
||||
// 모바일 카드 렌더링
|
||||
const renderMobileCard = (
|
||||
order: WorkOrder,
|
||||
index: number,
|
||||
globalIndex: number,
|
||||
isSelected: boolean,
|
||||
onToggle: () => void
|
||||
) => {
|
||||
return (
|
||||
<ListMobileCard
|
||||
id={order.id}
|
||||
isSelected={isSelected}
|
||||
onToggleSelection={onToggle}
|
||||
onClick={() => handleView(order.id)}
|
||||
headerBadges={
|
||||
<>
|
||||
<Badge variant="outline" className="text-xs">#{globalIndex}</Badge>
|
||||
<Badge variant="outline" className="text-xs">{order.workOrderNo}</Badge>
|
||||
</>
|
||||
}
|
||||
title={order.projectName}
|
||||
statusBadge={
|
||||
<Badge className={`${WORK_ORDER_STATUS_COLORS[order.status]} border-0`}>
|
||||
{WORK_ORDER_STATUS_LABELS[order.status]}
|
||||
</Badge>
|
||||
}
|
||||
infoGrid={
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-3">
|
||||
<InfoField label="공정" value={PROCESS_TYPE_LABELS[order.processType]} />
|
||||
<InfoField label="로트번호" value={order.lotNo} />
|
||||
<InfoField label="발주처" value={order.client} />
|
||||
<InfoField label="작업자" value={order.assignee || '-'} />
|
||||
<InfoField label="지시일" value={order.orderDate} />
|
||||
<InfoField label="출고예정일" value={order.shipmentDate} />
|
||||
<InfoField label="현장순위" value={order.priority} />
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
// 헤더 액션
|
||||
const headerActions = (
|
||||
<Button onClick={handleCreate}>
|
||||
<Plus className="w-4 h-4 mr-1.5" />
|
||||
등록
|
||||
</Button>
|
||||
);
|
||||
|
||||
return (
|
||||
<IntegratedListTemplateV2<WorkOrder>
|
||||
title="작업지시 목록"
|
||||
description="생산 작업지시 관리"
|
||||
icon={FileText}
|
||||
headerActions={headerActions}
|
||||
stats={stats}
|
||||
searchValue={searchTerm}
|
||||
onSearchChange={handleSearchChange}
|
||||
searchPlaceholder="작업지시번호, 발주처, 현장명 검색..."
|
||||
tabs={tabs}
|
||||
activeTab={activeTab}
|
||||
onTabChange={handleTabChange}
|
||||
tableColumns={tableColumns}
|
||||
data={paginatedData}
|
||||
totalCount={filteredOrders.length}
|
||||
allData={filteredOrders}
|
||||
selectedItems={selectedItems}
|
||||
onToggleSelection={handleToggleSelection}
|
||||
onToggleSelectAll={handleToggleSelectAll}
|
||||
getItemId={(order) => order.id}
|
||||
renderTableRow={renderTableRow}
|
||||
renderMobileCard={renderMobileCard}
|
||||
pagination={pagination}
|
||||
/>
|
||||
);
|
||||
}
|
||||
10
src/components/production/WorkOrders/index.ts
Normal file
10
src/components/production/WorkOrders/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* 작업지시 관리 컴포넌트 내보내기
|
||||
*/
|
||||
|
||||
export * from './types';
|
||||
export * from './mockData';
|
||||
export { WorkOrderList } from './WorkOrderList';
|
||||
export { WorkOrderCreate } from './WorkOrderCreate';
|
||||
export { WorkOrderDetail } from './WorkOrderDetail';
|
||||
export { SalesOrderSelectModal } from './SalesOrderSelectModal';
|
||||
488
src/components/production/WorkOrders/mockData.ts
Normal file
488
src/components/production/WorkOrders/mockData.ts
Normal file
@@ -0,0 +1,488 @@
|
||||
/**
|
||||
* 작업지시 관리 목업 데이터
|
||||
*/
|
||||
|
||||
import type {
|
||||
WorkOrder,
|
||||
SalesOrder,
|
||||
WorkOrderStats,
|
||||
BendingDetail,
|
||||
} from './types';
|
||||
|
||||
// 전개도 상세 목업 (절곡용)
|
||||
const bendingDetailsSample: BendingDetail[] = [
|
||||
{
|
||||
id: 'bd-1',
|
||||
code: 'SD30',
|
||||
name: '엘바',
|
||||
material: 'E.G.I 1.6T',
|
||||
quantity: 4,
|
||||
developWidth: '500mm',
|
||||
length: '3000mm',
|
||||
weight: '0.9kg',
|
||||
note: '-',
|
||||
developDimension: '75',
|
||||
},
|
||||
{
|
||||
id: 'bd-2',
|
||||
code: 'SD31',
|
||||
name: '하장바',
|
||||
material: 'E.G.I 1.6T',
|
||||
quantity: 2,
|
||||
developWidth: '500mm',
|
||||
length: '3000mm',
|
||||
weight: '1.158kg',
|
||||
note: '-',
|
||||
developDimension: '67→126→165→178→193',
|
||||
},
|
||||
{
|
||||
id: 'bd-3',
|
||||
code: 'SD32',
|
||||
name: '짜부가스켓',
|
||||
material: 'E.G.I 0.8T',
|
||||
quantity: 4,
|
||||
developWidth: '500mm',
|
||||
length: '3000mm',
|
||||
weight: '0.576kg',
|
||||
note: '80*4,50*8',
|
||||
developDimension: '48',
|
||||
},
|
||||
{
|
||||
id: 'bd-4',
|
||||
code: 'SD33',
|
||||
name: '50평철',
|
||||
material: 'E.G.I 1.2T',
|
||||
quantity: 2,
|
||||
developWidth: '500mm',
|
||||
length: '3000mm',
|
||||
weight: '0.3kg',
|
||||
note: '-',
|
||||
developDimension: '50',
|
||||
},
|
||||
{
|
||||
id: 'bd-5',
|
||||
code: 'SD36',
|
||||
name: '밑면 점검구',
|
||||
material: 'E.G.I 1.6T',
|
||||
quantity: 2,
|
||||
developWidth: '500mm',
|
||||
length: '2438mm',
|
||||
weight: '0.98kg',
|
||||
note: '500*380',
|
||||
developDimension: '90→240→310',
|
||||
},
|
||||
{
|
||||
id: 'bd-6',
|
||||
code: 'SD37',
|
||||
name: '후면코너부',
|
||||
material: 'E.G.I 1.2T',
|
||||
quantity: 4,
|
||||
developWidth: '500mm',
|
||||
length: '1219mm',
|
||||
weight: '0.45kg',
|
||||
note: '-',
|
||||
developDimension: '35→85→120',
|
||||
},
|
||||
];
|
||||
|
||||
// 작업지시 목업 데이터
|
||||
export const mockWorkOrders: WorkOrder[] = [
|
||||
{
|
||||
id: 'wo-1',
|
||||
workOrderNo: 'KD-WO-251217-12',
|
||||
lotNo: 'KD-TS-251217-10',
|
||||
processType: 'screen',
|
||||
status: 'shipped',
|
||||
client: '두산건설(주)',
|
||||
projectName: '위브 청라 센트럴파크',
|
||||
dueDate: '2025-12-30',
|
||||
assignee: '최스크린',
|
||||
orderDate: '2025-12-17',
|
||||
shipmentDate: '2025-12-28',
|
||||
isAssigned: true,
|
||||
isStarted: true,
|
||||
priority: 5,
|
||||
currentStep: 5,
|
||||
items: [
|
||||
{ id: 'item-1', no: 1, status: 'completed', productName: '스크린 셔터 (표준형)', floorCode: '1층/I-01', specification: '3500×2500', quantity: 1 },
|
||||
{ id: 'item-2', no: 2, status: 'completed', productName: '스크린 셔터 (표준형)', floorCode: '1층/I-02', specification: '3500×2500', quantity: 1 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'wo-2',
|
||||
workOrderNo: 'KD-WO-251217-11',
|
||||
lotNo: 'KD-TS-251217-09',
|
||||
processType: 'screen',
|
||||
status: 'in_progress',
|
||||
client: '대영건설(주)',
|
||||
projectName: '대시앙 동탄 레이크파크',
|
||||
dueDate: '2026-02-08',
|
||||
assignee: '김스크린',
|
||||
orderDate: '2025-12-17',
|
||||
shipmentDate: '2026-02-01',
|
||||
isAssigned: true,
|
||||
isStarted: true,
|
||||
priority: 5,
|
||||
currentStep: 3,
|
||||
items: [
|
||||
{ id: 'item-3', no: 1, status: 'waiting', productName: '스크린 사타 (표준형)', floorCode: '1층/H-01', specification: '4000×3000', quantity: 1 },
|
||||
{ id: 'item-4', no: 2, status: 'waiting', productName: '스크린 사타 (표준형)', floorCode: '2층/H-02', specification: '4000×3000', quantity: 1 },
|
||||
],
|
||||
issues: [
|
||||
{
|
||||
id: 'issue-1',
|
||||
status: 'processing',
|
||||
type: '불량품발생',
|
||||
description: '앤드락 접착불량 - 전체 재작업 필요',
|
||||
createdAt: '2025-12-20',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'wo-3',
|
||||
workOrderNo: 'KD-WO-251217-10',
|
||||
lotNo: 'KD-TS-251217-08',
|
||||
processType: 'screen',
|
||||
status: 'waiting',
|
||||
client: '포레나',
|
||||
projectName: '포레나 수지 더 퍼스트',
|
||||
dueDate: '2026-02-13',
|
||||
assignee: '-',
|
||||
orderDate: '2025-12-17',
|
||||
shipmentDate: '2026-02-05',
|
||||
isAssigned: false,
|
||||
isStarted: false,
|
||||
priority: 7,
|
||||
currentStep: 0,
|
||||
items: [
|
||||
{ id: 'item-5', no: 1, status: 'waiting', productName: '스크린 셔터 (표준형)', floorCode: '1층/A-01', specification: '3000×2500', quantity: 1 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'wo-4',
|
||||
workOrderNo: 'KD-WO-251217-09',
|
||||
lotNo: 'KD-TS-251217-07',
|
||||
processType: 'slat',
|
||||
status: 'in_progress',
|
||||
client: '호반건설(주)',
|
||||
projectName: '써밋 광교 리버파크',
|
||||
dueDate: '2026-01-30',
|
||||
assignee: '이슬랫',
|
||||
orderDate: '2025-12-17',
|
||||
shipmentDate: '2026-01-20',
|
||||
isAssigned: true,
|
||||
isStarted: true,
|
||||
priority: 5,
|
||||
currentStep: 1,
|
||||
items: [
|
||||
{ id: 'item-6', no: 1, status: 'waiting', productName: '철재 슬랫 셔터', floorCode: '3층/F-05', specification: '3500×2500', quantity: 1 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'wo-5',
|
||||
workOrderNo: 'KD-WO-251217-08',
|
||||
lotNo: 'KD-TS-251217-07',
|
||||
processType: 'screen',
|
||||
status: 'completed',
|
||||
client: '삼성물산(주)',
|
||||
projectName: '써밋 광교 리버파크',
|
||||
dueDate: '2026-01-18',
|
||||
assignee: '박스크린',
|
||||
orderDate: '2025-12-17',
|
||||
shipmentDate: '2026-01-10',
|
||||
isAssigned: true,
|
||||
isStarted: true,
|
||||
priority: 5,
|
||||
currentStep: 5,
|
||||
items: [
|
||||
{ id: 'item-7', no: 1, status: 'completed', productName: '스크린 셔터 (표준형)', floorCode: '1층/A-01', specification: '3500×2500', quantity: 1 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'wo-6',
|
||||
workOrderNo: 'KD-WO-FLD-251216-01',
|
||||
lotNo: 'KD-TS-251216-06',
|
||||
processType: 'bending',
|
||||
status: 'completed',
|
||||
client: '삼성물산(주)',
|
||||
projectName: '[E2E테스트] 절곡 전용 현장',
|
||||
dueDate: '2025-12-28',
|
||||
assignee: '최절곡',
|
||||
orderDate: '2025-12-16',
|
||||
shipmentDate: '2025-12-24',
|
||||
isAssigned: true,
|
||||
isStarted: true,
|
||||
priority: 3,
|
||||
currentStep: 4,
|
||||
items: [
|
||||
{ id: 'item-8', no: 1, status: 'waiting', productName: '방화셔터 절곡 부품 SET (E2E)', floorCode: '테스트층/E2E-G-01', specification: '3000×4000', quantity: 1 },
|
||||
],
|
||||
bendingDetails: bendingDetailsSample,
|
||||
issues: [
|
||||
{
|
||||
id: 'issue-2',
|
||||
status: 'processing',
|
||||
type: '불량품발생',
|
||||
description: '중간검사 불합격 - 절곡 각도 불량 1EA (90° 기준 ±2° 초과)',
|
||||
createdAt: '2025-12-22',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'wo-7',
|
||||
workOrderNo: 'KD-WO-251217-07',
|
||||
lotNo: 'KD-TS-251217-07',
|
||||
processType: 'screen',
|
||||
status: 'completed',
|
||||
client: '삼성물산(주)',
|
||||
projectName: '써밋 광교 리버파크',
|
||||
dueDate: '2026-01-08',
|
||||
assignee: '김스크린',
|
||||
orderDate: '2025-12-17',
|
||||
shipmentDate: '2025-12-30',
|
||||
isAssigned: true,
|
||||
isStarted: true,
|
||||
priority: 5,
|
||||
currentStep: 5,
|
||||
items: [],
|
||||
},
|
||||
{
|
||||
id: 'wo-8',
|
||||
workOrderNo: 'KD-WO-251217-06',
|
||||
lotNo: 'KD-TS-251217-05',
|
||||
processType: 'screen',
|
||||
status: 'completed',
|
||||
client: '대산',
|
||||
projectName: '대산 송도 마린베이',
|
||||
dueDate: '2026-01-28',
|
||||
assignee: '최스크린',
|
||||
orderDate: '2025-12-17',
|
||||
shipmentDate: '2026-01-20',
|
||||
isAssigned: true,
|
||||
isStarted: true,
|
||||
priority: 5,
|
||||
currentStep: 5,
|
||||
items: [],
|
||||
},
|
||||
{
|
||||
id: 'wo-9',
|
||||
workOrderNo: 'KD-WO-251217-05',
|
||||
lotNo: 'KD-TS-251217-04',
|
||||
processType: 'slat',
|
||||
status: 'completed',
|
||||
client: '자이',
|
||||
projectName: '자이 위례 더 퍼스트',
|
||||
dueDate: '2026-01-28',
|
||||
assignee: '이슬랫',
|
||||
orderDate: '2025-12-17',
|
||||
shipmentDate: '2026-01-20',
|
||||
isAssigned: true,
|
||||
isStarted: true,
|
||||
priority: 5,
|
||||
currentStep: 5,
|
||||
items: [],
|
||||
},
|
||||
{
|
||||
id: 'wo-10',
|
||||
workOrderNo: 'KD-WO-251217-04',
|
||||
lotNo: 'KD-TS-251217-03',
|
||||
processType: 'screen',
|
||||
status: 'completed',
|
||||
client: '푸르지오',
|
||||
projectName: '푸르지오 일산 센트럴파크',
|
||||
dueDate: '2026-01-23',
|
||||
assignee: '박스크린',
|
||||
orderDate: '2025-12-17',
|
||||
shipmentDate: '2026-01-15',
|
||||
isAssigned: true,
|
||||
isStarted: true,
|
||||
priority: 5,
|
||||
currentStep: 5,
|
||||
items: [],
|
||||
},
|
||||
{
|
||||
id: 'wo-11',
|
||||
workOrderNo: 'KD-WO-251217-03',
|
||||
lotNo: 'KD-TS-251217-02',
|
||||
processType: 'bending',
|
||||
status: 'completed',
|
||||
client: '힐스테이트',
|
||||
projectName: '힐스테이트 판교 더 퍼스트',
|
||||
dueDate: '2026-01-18',
|
||||
assignee: '최절곡',
|
||||
orderDate: '2025-12-17',
|
||||
shipmentDate: '2026-01-10',
|
||||
isAssigned: true,
|
||||
isStarted: true,
|
||||
priority: 5,
|
||||
currentStep: 4,
|
||||
items: [],
|
||||
},
|
||||
{
|
||||
id: 'wo-12',
|
||||
workOrderNo: 'KD-WO-251217-02',
|
||||
lotNo: 'KD-TS-251217-82',
|
||||
processType: 'screen',
|
||||
status: 'completed',
|
||||
client: '힐스테이트',
|
||||
projectName: '힐스테이트 판교 더 머스트',
|
||||
dueDate: '2026-01-08',
|
||||
assignee: '김스크린',
|
||||
orderDate: '2025-12-17',
|
||||
shipmentDate: '2025-12-30',
|
||||
isAssigned: true,
|
||||
isStarted: true,
|
||||
priority: 5,
|
||||
currentStep: 5,
|
||||
items: [],
|
||||
},
|
||||
{
|
||||
id: 'wo-13',
|
||||
workOrderNo: 'KD-WO-251217-01',
|
||||
lotNo: 'KD-TS-251217-81',
|
||||
processType: 'screen',
|
||||
status: 'completed',
|
||||
client: '레미안',
|
||||
projectName: '레미안 강남 프레스티지',
|
||||
dueDate: '2026-01-13',
|
||||
assignee: '최스크린',
|
||||
orderDate: '2025-12-17',
|
||||
shipmentDate: '2026-01-05',
|
||||
isAssigned: true,
|
||||
isStarted: true,
|
||||
priority: 5,
|
||||
currentStep: 5,
|
||||
items: [],
|
||||
},
|
||||
{
|
||||
id: 'wo-14',
|
||||
workOrderNo: 'KD-WO-FLD-251215-01',
|
||||
lotNo: 'KD-TS-251215-01',
|
||||
processType: 'bending',
|
||||
status: 'completed',
|
||||
client: '삼성물산(주)',
|
||||
projectName: '송도 아파트 B동',
|
||||
dueDate: '2025-12-30',
|
||||
assignee: '최절곡',
|
||||
orderDate: '2025-12-15',
|
||||
shipmentDate: '2025-12-25',
|
||||
isAssigned: true,
|
||||
isStarted: true,
|
||||
priority: 5,
|
||||
currentStep: 4,
|
||||
items: [],
|
||||
},
|
||||
{
|
||||
id: 'wo-15',
|
||||
workOrderNo: 'KD-WO-FLD-251212-01',
|
||||
lotNo: 'KD-TS-251212-81',
|
||||
processType: 'bending',
|
||||
status: 'completed',
|
||||
client: '삼성물산(주)',
|
||||
projectName: '판교 롯데센터',
|
||||
dueDate: '2025-12-26',
|
||||
assignee: '최절곡',
|
||||
orderDate: '2025-12-13',
|
||||
shipmentDate: '2025-12-22',
|
||||
isAssigned: true,
|
||||
isStarted: true,
|
||||
priority: 5,
|
||||
currentStep: 4,
|
||||
items: [],
|
||||
},
|
||||
{
|
||||
id: 'wo-16',
|
||||
workOrderNo: 'KD-WO-FLD-251210-01',
|
||||
lotNo: 'KD-TS-251210-81',
|
||||
processType: 'screen',
|
||||
status: 'completed',
|
||||
client: '삼성물산(주)',
|
||||
projectName: '강남 타워 신축현장',
|
||||
dueDate: '2025-12-25',
|
||||
assignee: '김스크린',
|
||||
orderDate: '2025-12-10',
|
||||
shipmentDate: '2025-12-20',
|
||||
isAssigned: true,
|
||||
isStarted: true,
|
||||
priority: 5,
|
||||
currentStep: 5,
|
||||
items: [],
|
||||
},
|
||||
{
|
||||
id: 'wo-17',
|
||||
workOrderNo: 'KD-WO-FLD-251208-01',
|
||||
lotNo: 'KD-TS-251288-01',
|
||||
processType: 'screen',
|
||||
status: 'completed',
|
||||
client: '삼성물산(주)',
|
||||
projectName: '배곧더 타워',
|
||||
dueDate: '2025-12-22',
|
||||
assignee: '박스크린',
|
||||
orderDate: '2025-12-08',
|
||||
shipmentDate: '2025-12-18',
|
||||
isAssigned: true,
|
||||
isStarted: true,
|
||||
priority: 5,
|
||||
currentStep: 5,
|
||||
items: [],
|
||||
},
|
||||
];
|
||||
|
||||
// 수주 목록 목업 데이터
|
||||
export const mockSalesOrders: SalesOrder[] = [
|
||||
{
|
||||
id: 'so-1',
|
||||
orderNo: 'KD-TS-251201-01',
|
||||
status: '생산지시완료',
|
||||
client: '삼성물산(주)',
|
||||
projectName: '삼성물산 레미안 강남 1차',
|
||||
dueDate: '2025-12-20',
|
||||
itemCount: 2,
|
||||
splitCount: 1,
|
||||
},
|
||||
{
|
||||
id: 'so-2',
|
||||
orderNo: 'KD-TS-251205-01-A',
|
||||
status: '생산지시완료',
|
||||
client: '삼성물산(주)',
|
||||
projectName: '삼성물산 레미안 강남 1차',
|
||||
dueDate: '2025-12-28',
|
||||
itemCount: 1,
|
||||
splitCount: 1,
|
||||
},
|
||||
{
|
||||
id: 'so-3',
|
||||
orderNo: 'KD-TS-251206-01',
|
||||
status: '생산지시완료',
|
||||
client: '(주)서울인테리어',
|
||||
projectName: '강남 오피스타워 인테리어',
|
||||
dueDate: '2025-12-29',
|
||||
itemCount: 2,
|
||||
splitCount: 2,
|
||||
},
|
||||
{
|
||||
id: 'so-4',
|
||||
orderNo: 'KD-TS-251207-01',
|
||||
status: '생산지시완료',
|
||||
client: '삼성물산(주)',
|
||||
projectName: '삼성물산 레미안 강남 2차',
|
||||
dueDate: '2025-12-28',
|
||||
itemCount: 1,
|
||||
splitCount: 1,
|
||||
},
|
||||
];
|
||||
|
||||
// 통계 계산
|
||||
export function calculateStats(orders: WorkOrder[]): WorkOrderStats {
|
||||
return {
|
||||
total: orders.length,
|
||||
unassigned: orders.filter((o) => o.status === 'unassigned').length,
|
||||
pending: orders.filter((o) => o.status === 'pending').length,
|
||||
waiting: orders.filter((o) => o.status === 'waiting').length,
|
||||
inProgress: orders.filter((o) => o.status === 'in_progress').length,
|
||||
completed: orders.filter((o) => o.status === 'completed' || o.status === 'shipped').length,
|
||||
};
|
||||
}
|
||||
|
||||
// 기본 통계
|
||||
export const mockStats: WorkOrderStats = calculateStats(mockWorkOrders);
|
||||
205
src/components/production/WorkOrders/types.ts
Normal file
205
src/components/production/WorkOrders/types.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
/**
|
||||
* 작업지시 관리 타입 정의
|
||||
*/
|
||||
|
||||
// 공정 구분
|
||||
export type ProcessType = 'screen' | 'slat' | 'bending';
|
||||
|
||||
export const PROCESS_TYPE_LABELS: Record<ProcessType, string> = {
|
||||
screen: '스크린',
|
||||
slat: '슬랫',
|
||||
bending: '절곡',
|
||||
};
|
||||
|
||||
// 작업 상태
|
||||
export type WorkOrderStatus =
|
||||
| 'unassigned' // 미배정
|
||||
| 'pending' // 승인대기
|
||||
| 'waiting' // 작업대기
|
||||
| 'in_progress' // 작업중
|
||||
| 'completed' // 작업완료
|
||||
| 'shipped'; // 출하완료
|
||||
|
||||
export const WORK_ORDER_STATUS_LABELS: Record<WorkOrderStatus, string> = {
|
||||
unassigned: '미배정',
|
||||
pending: '승인대기',
|
||||
waiting: '작업대기',
|
||||
in_progress: '작업중',
|
||||
completed: '작업완료',
|
||||
shipped: '출하완료',
|
||||
};
|
||||
|
||||
export const WORK_ORDER_STATUS_COLORS: Record<WorkOrderStatus, string> = {
|
||||
unassigned: 'bg-gray-100 text-gray-700',
|
||||
pending: 'bg-yellow-100 text-yellow-700',
|
||||
waiting: 'bg-blue-100 text-blue-700',
|
||||
in_progress: 'bg-green-100 text-green-700',
|
||||
completed: 'bg-purple-100 text-purple-700',
|
||||
shipped: 'bg-slate-100 text-slate-700',
|
||||
};
|
||||
|
||||
// 스크린 공정 단계
|
||||
export type ScreenProcessStep =
|
||||
| 'cutting' // 원단절단
|
||||
| 'sewing' // 미싱
|
||||
| 'endlock' // 앤드락작업
|
||||
| 'inspection' // 중간검사
|
||||
| 'packing'; // 포장
|
||||
|
||||
export const SCREEN_PROCESS_STEPS: { key: ScreenProcessStep; label: string; order: number }[] = [
|
||||
{ key: 'cutting', label: '원단절단', order: 1 },
|
||||
{ key: 'sewing', label: '미싱', order: 2 },
|
||||
{ key: 'endlock', label: '앤드락작업', order: 3 },
|
||||
{ key: 'inspection', label: '중간검사', order: 4 },
|
||||
{ key: 'packing', label: '포장', order: 5 },
|
||||
];
|
||||
|
||||
// 슬랫 공정 단계
|
||||
export type SlatProcessStep =
|
||||
| 'coil_cutting' // 코일절단
|
||||
| 'forming' // 성형
|
||||
| 'finishing' // 미미작업
|
||||
| 'inspection' // 검사
|
||||
| 'packing'; // 포장
|
||||
|
||||
export const SLAT_PROCESS_STEPS: { key: SlatProcessStep; label: string; order: number }[] = [
|
||||
{ key: 'coil_cutting', label: '코일절단', order: 1 },
|
||||
{ key: 'forming', label: '성형', order: 2 },
|
||||
{ key: 'finishing', label: '미미작업', order: 3 },
|
||||
{ key: 'inspection', label: '검사', order: 4 },
|
||||
{ key: 'packing', label: '포장', order: 5 },
|
||||
];
|
||||
|
||||
// 절곡 공정 단계
|
||||
export type BendingProcessStep =
|
||||
| 'guide_rail' // 가이드레일 제작
|
||||
| 'case' // 케이스 제작
|
||||
| 'bottom_finish' // 하단마감재 제작
|
||||
| 'inspection'; // 검사
|
||||
|
||||
export const BENDING_PROCESS_STEPS: { key: BendingProcessStep; label: string; order: number }[] = [
|
||||
{ key: 'guide_rail', label: '가이드레일 제작', order: 1 },
|
||||
{ key: 'case', label: '케이스 제작', order: 2 },
|
||||
{ key: 'bottom_finish', label: '하단마감재 제작', order: 3 },
|
||||
{ key: 'inspection', label: '검사', order: 4 },
|
||||
];
|
||||
|
||||
// 품목 상태
|
||||
export type ItemStatus = 'waiting' | 'in_progress' | 'completed';
|
||||
|
||||
export const ITEM_STATUS_LABELS: Record<ItemStatus, string> = {
|
||||
waiting: '대기',
|
||||
in_progress: '작업중',
|
||||
completed: '완료',
|
||||
};
|
||||
|
||||
// 작업 품목
|
||||
export interface WorkOrderItem {
|
||||
id: string;
|
||||
no: number;
|
||||
status: ItemStatus;
|
||||
productName: string;
|
||||
floorCode: string; // 층/부호
|
||||
specification: string; // 규격
|
||||
quantity: number;
|
||||
}
|
||||
|
||||
// 전개도 상세 (절곡용)
|
||||
export interface BendingDetail {
|
||||
id: string;
|
||||
code: string; // SD30, SD31 등
|
||||
name: string; // 엘바, 하장바 등
|
||||
material: string; // E.G.I 1.6T 등
|
||||
quantity: number; // 수량
|
||||
developWidth: string; // 전개폭
|
||||
length: string; // 길이
|
||||
weight: string; // 중량
|
||||
note: string; // 비고
|
||||
developDimension: string; // 전개치수
|
||||
imageUrl?: string; // 전개도 이미지
|
||||
}
|
||||
|
||||
// 이슈
|
||||
export interface WorkOrderIssue {
|
||||
id: string;
|
||||
status: 'pending' | 'processing' | 'resolved';
|
||||
type: string; // 불량품발생 등
|
||||
description: string; // 상세 설명
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export const ISSUE_STATUS_LABELS: Record<WorkOrderIssue['status'], string> = {
|
||||
pending: '대기중',
|
||||
processing: '처리중',
|
||||
resolved: '해결됨',
|
||||
};
|
||||
|
||||
// 작업지시 메인 타입
|
||||
export interface WorkOrder {
|
||||
id: string;
|
||||
workOrderNo: string; // 작업지시번호 (KD-WO-251217-12)
|
||||
lotNo: string; // 로트번호 (KD-TS-251217-10)
|
||||
processType: ProcessType; // 공정구분
|
||||
status: WorkOrderStatus; // 작업상태
|
||||
|
||||
// 기본 정보
|
||||
client: string; // 발주처
|
||||
projectName: string; // 현장명
|
||||
dueDate: string; // 납기일
|
||||
assignee: string; // 작업자
|
||||
|
||||
// 날짜 정보
|
||||
orderDate: string; // 지시일
|
||||
shipmentDate: string; // 출고예정일
|
||||
|
||||
// 플래그
|
||||
isAssigned: boolean; // 배정 여부
|
||||
isStarted: boolean; // 시작 여부
|
||||
|
||||
// 우선순위
|
||||
priority: number; // 1~9 (1=긴급, 9=낮음)
|
||||
|
||||
// 품목
|
||||
items: WorkOrderItem[];
|
||||
|
||||
// 공정 진행 상태 (현재 단계)
|
||||
currentStep: number;
|
||||
|
||||
// 절곡 전용 - 전개도 상세
|
||||
bendingDetails?: BendingDetail[];
|
||||
|
||||
// 이슈
|
||||
issues?: WorkOrderIssue[];
|
||||
|
||||
// 비고
|
||||
note?: string;
|
||||
}
|
||||
|
||||
// 수주 정보 (모달용)
|
||||
export interface SalesOrder {
|
||||
id: string;
|
||||
orderNo: string; // KD-TS-251201-01
|
||||
status: string; // 생산지시완료 등
|
||||
client: string; // 삼성물산(주)
|
||||
projectName: string; // 삼성물산 레미안 강남 1차
|
||||
dueDate: string; // 납기
|
||||
itemCount: number; // 품목수
|
||||
splitCount: number; // 분할건
|
||||
}
|
||||
|
||||
// 리스트 필터
|
||||
export interface WorkOrderFilter {
|
||||
search: string;
|
||||
status: WorkOrderStatus | 'all';
|
||||
processType: ProcessType | 'all';
|
||||
}
|
||||
|
||||
// 통계
|
||||
export interface WorkOrderStats {
|
||||
total: number;
|
||||
unassigned: number;
|
||||
pending: number;
|
||||
waiting: number;
|
||||
inProgress: number;
|
||||
completed: number;
|
||||
}
|
||||
296
src/components/production/WorkResults/WorkResultList.tsx
Normal file
296
src/components/production/WorkResults/WorkResultList.tsx
Normal file
@@ -0,0 +1,296 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 작업실적 조회 목록 페이지
|
||||
* IntegratedListTemplateV2 패턴 적용
|
||||
*/
|
||||
|
||||
import { useState, useMemo, useCallback } from 'react';
|
||||
import {
|
||||
BarChart3,
|
||||
Package,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
Percent,
|
||||
Download,
|
||||
CheckCircle,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { TableCell, TableRow } from '@/components/ui/table';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import {
|
||||
IntegratedListTemplateV2,
|
||||
TableColumn,
|
||||
StatCard,
|
||||
} from '@/components/templates/IntegratedListTemplateV2';
|
||||
import { ListMobileCard, InfoField } from '@/components/organisms/ListMobileCard';
|
||||
import { mockWorkResults, mockStats } from './mockData';
|
||||
import { PROCESS_TYPE_LABELS } from '../WorkOrders/types';
|
||||
import type { WorkResult } from './types';
|
||||
|
||||
// 페이지당 항목 수
|
||||
const ITEMS_PER_PAGE = 20;
|
||||
|
||||
export function WorkResultList() {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set());
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
|
||||
// 통계 카드
|
||||
const stats: StatCard[] = [
|
||||
{
|
||||
label: '총 생산수량',
|
||||
value: `${mockStats.totalProduction}개`,
|
||||
icon: Package,
|
||||
iconColor: 'text-gray-600',
|
||||
},
|
||||
{
|
||||
label: '양품수량',
|
||||
value: `${mockStats.totalGood}개`,
|
||||
icon: CheckCircle2,
|
||||
iconColor: 'text-green-600',
|
||||
},
|
||||
{
|
||||
label: '불량수량',
|
||||
value: `${mockStats.totalDefect}개`,
|
||||
icon: XCircle,
|
||||
iconColor: 'text-red-600',
|
||||
},
|
||||
{
|
||||
label: '불량률',
|
||||
value: `${mockStats.defectRate}%`,
|
||||
icon: Percent,
|
||||
iconColor: 'text-orange-600',
|
||||
},
|
||||
];
|
||||
|
||||
// 테이블 컬럼
|
||||
const tableColumns: TableColumn[] = [
|
||||
{ key: 'no', label: '번호', className: 'w-[60px] text-center' },
|
||||
{ key: 'lotNo', label: '로트번호', className: 'min-w-[150px]' },
|
||||
{ key: 'workDate', label: '작업일', className: 'w-[100px]' },
|
||||
{ key: 'workOrderNo', label: '작업지시번호', className: 'min-w-[140px]' },
|
||||
{ key: 'processType', label: '공정', className: 'w-[80px]' },
|
||||
{ key: 'productName', label: '품목명', className: 'min-w-[180px]' },
|
||||
{ key: 'specification', label: '규격', className: 'w-[100px]' },
|
||||
{ key: 'productionQty', label: '생산수량', className: 'w-[80px] text-center' },
|
||||
{ key: 'goodQty', label: '양품수량', className: 'w-[80px] text-center' },
|
||||
{ key: 'defectQty', label: '불량수량', className: 'w-[80px] text-center' },
|
||||
{ key: 'defectRate', label: '불량률', className: 'w-[80px] text-center' },
|
||||
{ key: 'inspection', label: '검사', className: 'w-[60px] text-center' },
|
||||
{ key: 'packaging', label: '포장', className: 'w-[60px] text-center' },
|
||||
{ key: 'worker', label: '작업자', className: 'w-[80px]' },
|
||||
];
|
||||
|
||||
// 필터링된 데이터
|
||||
const filteredResults = useMemo(() => {
|
||||
let result = [...mockWorkResults];
|
||||
|
||||
// 검색 필터
|
||||
if (searchTerm) {
|
||||
const term = searchTerm.toLowerCase();
|
||||
result = result.filter(
|
||||
(r) =>
|
||||
r.lotNo.toLowerCase().includes(term) ||
|
||||
r.workOrderNo.toLowerCase().includes(term) ||
|
||||
r.productName.toLowerCase().includes(term)
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [searchTerm]);
|
||||
|
||||
// 페이지네이션 데이터
|
||||
const paginatedData = useMemo(() => {
|
||||
const startIndex = (currentPage - 1) * ITEMS_PER_PAGE;
|
||||
return filteredResults.slice(startIndex, startIndex + ITEMS_PER_PAGE);
|
||||
}, [filteredResults, currentPage]);
|
||||
|
||||
// 페이지네이션 설정
|
||||
const pagination = {
|
||||
currentPage,
|
||||
totalPages: Math.ceil(filteredResults.length / ITEMS_PER_PAGE),
|
||||
totalItems: filteredResults.length,
|
||||
itemsPerPage: ITEMS_PER_PAGE,
|
||||
onPageChange: setCurrentPage,
|
||||
};
|
||||
|
||||
// 선택 핸들러
|
||||
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((r) => r.id)));
|
||||
}
|
||||
}, [paginatedData, selectedItems.size]);
|
||||
|
||||
// 검색 변경 시 페이지 리셋
|
||||
const handleSearchChange = (value: string) => {
|
||||
setSearchTerm(value);
|
||||
setCurrentPage(1);
|
||||
};
|
||||
|
||||
// 엑셀 다운로드
|
||||
const handleExcelDownload = useCallback(() => {
|
||||
console.log('엑셀 다운로드:', filteredResults);
|
||||
// TODO: 엑셀 다운로드 기능 구현
|
||||
}, [filteredResults]);
|
||||
|
||||
// 상세 보기
|
||||
const handleView = useCallback((id: string) => {
|
||||
console.log('상세 보기:', id);
|
||||
// TODO: 상세 보기 기능 구현
|
||||
}, []);
|
||||
|
||||
// 테이블 행 렌더링
|
||||
const renderTableRow = (result: WorkResult, index: number, globalIndex: number) => {
|
||||
const isSelected = selectedItems.has(result.id);
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
key={result.id}
|
||||
className={`cursor-pointer hover:bg-muted/50 ${isSelected ? 'bg-blue-50' : ''}`}
|
||||
onClick={() => handleView(result.id)}
|
||||
>
|
||||
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={() => handleToggleSelection(result.id)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-center text-muted-foreground">{globalIndex}</TableCell>
|
||||
<TableCell className="font-medium">{result.lotNo}</TableCell>
|
||||
<TableCell>{result.workDate}</TableCell>
|
||||
<TableCell>{result.workOrderNo}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{PROCESS_TYPE_LABELS[result.processType]}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="max-w-[200px] truncate">{result.productName}</TableCell>
|
||||
<TableCell>{result.specification}</TableCell>
|
||||
<TableCell className="text-center">{result.productionQty}</TableCell>
|
||||
<TableCell className="text-center">{result.goodQty}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<span className={result.defectQty > 0 ? 'text-red-600 font-medium' : ''}>
|
||||
{result.defectQty}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<span className={result.defectRate > 0 ? 'text-red-600 font-medium' : ''}>
|
||||
{result.defectRate}%
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{result.inspection ? (
|
||||
<CheckCircle className="w-4 h-4 text-green-600 mx-auto" />
|
||||
) : (
|
||||
<span className="text-muted-foreground">-</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{result.packaging ? (
|
||||
<CheckCircle className="w-4 h-4 text-green-600 mx-auto" />
|
||||
) : (
|
||||
<span className="text-muted-foreground">-</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>{result.worker}</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
};
|
||||
|
||||
// 모바일 카드 렌더링
|
||||
const renderMobileCard = (
|
||||
result: WorkResult,
|
||||
index: number,
|
||||
globalIndex: number,
|
||||
isSelected: boolean,
|
||||
onToggle: () => void
|
||||
) => {
|
||||
return (
|
||||
<ListMobileCard
|
||||
id={result.id}
|
||||
isSelected={isSelected}
|
||||
onToggleSelection={onToggle}
|
||||
onClick={() => handleView(result.id)}
|
||||
headerBadges={
|
||||
<>
|
||||
<Badge variant="outline" className="text-xs">#{globalIndex}</Badge>
|
||||
<Badge variant="outline" className="text-xs">{result.lotNo}</Badge>
|
||||
</>
|
||||
}
|
||||
title={result.productName}
|
||||
statusBadge={
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{PROCESS_TYPE_LABELS[result.processType]}
|
||||
</Badge>
|
||||
}
|
||||
infoGrid={
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-3">
|
||||
<InfoField label="작업일" value={result.workDate} />
|
||||
<InfoField label="작업지시번호" value={result.workOrderNo} />
|
||||
<InfoField label="규격" value={result.specification} />
|
||||
<InfoField label="작업자" value={result.worker} />
|
||||
<InfoField label="생산수량" value={`${result.productionQty}개`} />
|
||||
<InfoField label="양품수량" value={`${result.goodQty}개`} />
|
||||
<InfoField
|
||||
label="불량수량"
|
||||
value={`${result.defectQty}개`}
|
||||
className={result.defectQty > 0 ? 'text-red-600' : ''}
|
||||
/>
|
||||
<InfoField
|
||||
label="불량률"
|
||||
value={`${result.defectRate}%`}
|
||||
className={result.defectRate > 0 ? 'text-red-600' : ''}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
// 헤더 액션
|
||||
const headerActions = (
|
||||
<Button variant="outline" onClick={handleExcelDownload}>
|
||||
<Download className="w-4 h-4 mr-1.5" />
|
||||
엑셀 다운로드
|
||||
</Button>
|
||||
);
|
||||
|
||||
return (
|
||||
<IntegratedListTemplateV2<WorkResult>
|
||||
title="작업실적 조회"
|
||||
icon={BarChart3}
|
||||
headerActions={headerActions}
|
||||
stats={stats}
|
||||
searchValue={searchTerm}
|
||||
onSearchChange={handleSearchChange}
|
||||
searchPlaceholder="로트번호, 작업지시번호, 품목명 검색..."
|
||||
tableColumns={tableColumns}
|
||||
data={paginatedData}
|
||||
totalCount={filteredResults.length}
|
||||
allData={filteredResults}
|
||||
selectedItems={selectedItems}
|
||||
onToggleSelection={handleToggleSelection}
|
||||
onToggleSelectAll={handleToggleSelectAll}
|
||||
getItemId={(result) => result.id}
|
||||
renderTableRow={renderTableRow}
|
||||
renderMobileCard={renderMobileCard}
|
||||
pagination={pagination}
|
||||
/>
|
||||
);
|
||||
}
|
||||
6
src/components/production/WorkResults/index.ts
Normal file
6
src/components/production/WorkResults/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* 작업실적 조회 컴포넌트 exports
|
||||
*/
|
||||
|
||||
export { WorkResultList } from './WorkResultList';
|
||||
export type { WorkResult, WorkResultStats } from './types';
|
||||
155
src/components/production/WorkResults/mockData.ts
Normal file
155
src/components/production/WorkResults/mockData.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* 작업실적 조회 Mock 데이터
|
||||
*/
|
||||
|
||||
import type { WorkResult, WorkResultStats } from './types';
|
||||
|
||||
// Mock 작업실적 데이터
|
||||
export const mockWorkResults: WorkResult[] = [
|
||||
{
|
||||
id: 'wr-1',
|
||||
lotNo: 'KD-TS-250212-01-01',
|
||||
workDate: '2025-02-12',
|
||||
workOrderNo: 'KD-PL-250122-01',
|
||||
processType: 'screen',
|
||||
productName: '스크린 셔터 (프리미엄)',
|
||||
specification: '8000x2800',
|
||||
productionQty: 1,
|
||||
goodQty: 0,
|
||||
defectQty: 1,
|
||||
defectRate: 100.0,
|
||||
inspection: true,
|
||||
packaging: false,
|
||||
worker: '이성산',
|
||||
},
|
||||
{
|
||||
id: 'wr-2',
|
||||
lotNo: 'KD-TS-250210-01-02',
|
||||
workDate: '2025-02-10',
|
||||
workOrderNo: 'KD-PL-250120-01',
|
||||
processType: 'screen',
|
||||
productName: '스크린 셔터 (표준형)',
|
||||
specification: '6500x2400',
|
||||
productionQty: 1,
|
||||
goodQty: 1,
|
||||
defectQty: 0,
|
||||
defectRate: 0.0,
|
||||
inspection: true,
|
||||
packaging: true,
|
||||
worker: '김성산',
|
||||
},
|
||||
{
|
||||
id: 'wr-3',
|
||||
lotNo: 'KD-TS-250210-01-01',
|
||||
workDate: '2025-02-10',
|
||||
workOrderNo: 'KD-PL-250120-01',
|
||||
processType: 'screen',
|
||||
productName: '스크린 셔터 (표준형)',
|
||||
specification: '7660x2550',
|
||||
productionQty: 1,
|
||||
goodQty: 1,
|
||||
defectQty: 0,
|
||||
defectRate: 0.0,
|
||||
inspection: true,
|
||||
packaging: true,
|
||||
worker: '김성산',
|
||||
},
|
||||
{
|
||||
id: 'wr-4',
|
||||
lotNo: 'KD-TS-250208-01-01',
|
||||
workDate: '2025-02-08',
|
||||
workOrderNo: 'KD-PL-250118-01',
|
||||
processType: 'slat',
|
||||
productName: '철재 슬랫 셔터',
|
||||
specification: '5000x3000',
|
||||
productionQty: 2,
|
||||
goodQty: 2,
|
||||
defectQty: 0,
|
||||
defectRate: 0.0,
|
||||
inspection: true,
|
||||
packaging: true,
|
||||
worker: '박철호',
|
||||
},
|
||||
{
|
||||
id: 'wr-5',
|
||||
lotNo: 'KD-TS-250205-01-01',
|
||||
workDate: '2025-02-05',
|
||||
workOrderNo: 'KD-PL-250115-01',
|
||||
processType: 'bending',
|
||||
productName: '방화셔터 절곡 부품 SET',
|
||||
specification: '3000x4000',
|
||||
productionQty: 3,
|
||||
goodQty: 2,
|
||||
defectQty: 1,
|
||||
defectRate: 33.3,
|
||||
inspection: true,
|
||||
packaging: true,
|
||||
worker: '최절곡',
|
||||
},
|
||||
{
|
||||
id: 'wr-6',
|
||||
lotNo: 'KD-TS-250203-01-01',
|
||||
workDate: '2025-02-03',
|
||||
workOrderNo: 'KD-PL-250113-01',
|
||||
processType: 'screen',
|
||||
productName: '스크린 셔터 (프리미엄)',
|
||||
specification: '9000x3200',
|
||||
productionQty: 1,
|
||||
goodQty: 1,
|
||||
defectQty: 0,
|
||||
defectRate: 0.0,
|
||||
inspection: true,
|
||||
packaging: true,
|
||||
worker: '이성산',
|
||||
},
|
||||
{
|
||||
id: 'wr-7',
|
||||
lotNo: 'KD-TS-250201-01-01',
|
||||
workDate: '2025-02-01',
|
||||
workOrderNo: 'KD-PL-250111-01',
|
||||
processType: 'slat',
|
||||
productName: '알루미늄 슬랫 셔터',
|
||||
specification: '4500x2800',
|
||||
productionQty: 2,
|
||||
goodQty: 1,
|
||||
defectQty: 1,
|
||||
defectRate: 50.0,
|
||||
inspection: true,
|
||||
packaging: true,
|
||||
worker: '박철호',
|
||||
},
|
||||
{
|
||||
id: 'wr-8',
|
||||
lotNo: 'KD-TS-250130-01-01',
|
||||
workDate: '2025-01-30',
|
||||
workOrderNo: 'KD-PL-250109-01',
|
||||
processType: 'screen',
|
||||
productName: '스크린 셔터 (표준형)',
|
||||
specification: '7000x2600',
|
||||
productionQty: 1,
|
||||
goodQty: 1,
|
||||
defectQty: 0,
|
||||
defectRate: 0.0,
|
||||
inspection: true,
|
||||
packaging: true,
|
||||
worker: '김성산',
|
||||
},
|
||||
];
|
||||
|
||||
// 통계 계산
|
||||
export function calculateWorkResultStats(results: WorkResult[]): WorkResultStats {
|
||||
const totalProduction = results.reduce((sum, r) => sum + r.productionQty, 0);
|
||||
const totalGood = results.reduce((sum, r) => sum + r.goodQty, 0);
|
||||
const totalDefect = results.reduce((sum, r) => sum + r.defectQty, 0);
|
||||
const defectRate = totalProduction > 0 ? (totalDefect / totalProduction) * 100 : 0;
|
||||
|
||||
return {
|
||||
totalProduction,
|
||||
totalGood,
|
||||
totalDefect,
|
||||
defectRate: Math.round(defectRate * 10) / 10,
|
||||
};
|
||||
}
|
||||
|
||||
// 기본 통계
|
||||
export const mockStats: WorkResultStats = calculateWorkResultStats(mockWorkResults);
|
||||
31
src/components/production/WorkResults/types.ts
Normal file
31
src/components/production/WorkResults/types.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* 작업실적 조회 타입 정의
|
||||
*/
|
||||
|
||||
import { ProcessType } from '../WorkOrders/types';
|
||||
|
||||
// 작업실적 데이터
|
||||
export interface WorkResult {
|
||||
id: string;
|
||||
lotNo: string; // 로트번호
|
||||
workDate: string; // 작업일
|
||||
workOrderNo: string; // 작업지시번호
|
||||
processType: ProcessType; // 공정
|
||||
productName: string; // 품목명
|
||||
specification: string; // 규격
|
||||
productionQty: number; // 생산수량
|
||||
goodQty: number; // 양품수량
|
||||
defectQty: number; // 불량수량
|
||||
defectRate: number; // 불량률 (%)
|
||||
inspection: boolean; // 검사
|
||||
packaging: boolean; // 포장
|
||||
worker: string; // 작업자
|
||||
}
|
||||
|
||||
// 통계 데이터
|
||||
export interface WorkResultStats {
|
||||
totalProduction: number; // 총 생산수량
|
||||
totalGood: number; // 양품수량
|
||||
totalDefect: number; // 불량수량
|
||||
defectRate: number; // 불량률 (%)
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 전량완료 확인 다이얼로그
|
||||
*
|
||||
* "자재 투입이 필요합니다" 안내 후 확인 클릭 시 MaterialInputModal로 이동
|
||||
*/
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { PROCESS_LABELS } from '../ProductionDashboard/types';
|
||||
import type { WorkOrder } from '../ProductionDashboard/types';
|
||||
|
||||
interface CompletionConfirmDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
order: WorkOrder | null;
|
||||
onConfirm: () => void; // 확인 클릭 시 → MaterialInputModal 열기
|
||||
}
|
||||
|
||||
export function CompletionConfirmDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
order,
|
||||
onConfirm,
|
||||
}: CompletionConfirmDialogProps) {
|
||||
const handleConfirm = () => {
|
||||
onOpenChange(false);
|
||||
onConfirm(); // 부모에서 MaterialInputModal 열기
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
if (!order) return null;
|
||||
|
||||
return (
|
||||
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle className="text-orange-600">
|
||||
자재 투입이 필요합니다!
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription asChild>
|
||||
<div className="space-y-3">
|
||||
<div className="bg-gray-50 p-3 rounded-lg space-y-1 text-sm">
|
||||
<p>
|
||||
<span className="text-muted-foreground">작업지시:</span>{' '}
|
||||
<span className="font-medium text-foreground">{order.orderNo}</span>
|
||||
</p>
|
||||
<p>
|
||||
<span className="text-muted-foreground">공정:</span>{' '}
|
||||
<span className="font-medium text-foreground">
|
||||
{PROCESS_LABELS[order.process]}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-orange-600 font-medium">
|
||||
자재 투입 없이 완료 처리하시겠습니까?
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
(LOT 추적이 불가능해집니다)
|
||||
</p>
|
||||
</div>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel onClick={handleCancel}>취소</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleConfirm}>
|
||||
확인
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
28
src/components/production/WorkerScreen/CompletionToast.tsx
Normal file
28
src/components/production/WorkerScreen/CompletionToast.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 완료 토스트/뱃지 컴포넌트
|
||||
*
|
||||
* 검은색 라운드 배지, 상단 중앙 표시
|
||||
* 3초 후 자동 fade out
|
||||
*/
|
||||
|
||||
import { CheckCircle2 } from 'lucide-react';
|
||||
import type { CompletionToastInfo } from './types';
|
||||
|
||||
interface CompletionToastProps {
|
||||
info: CompletionToastInfo;
|
||||
}
|
||||
|
||||
export function CompletionToast({ info }: CompletionToastProps) {
|
||||
return (
|
||||
<div className="fixed top-4 left-1/2 -translate-x-1/2 z-50 animate-in fade-in slide-in-from-top-2 duration-300">
|
||||
<div className="bg-gray-900 text-white px-6 py-3 rounded-full shadow-lg flex items-center gap-2">
|
||||
<CheckCircle2 className="h-5 w-5 text-green-400" />
|
||||
<span className="font-medium">
|
||||
{info.orderNo} 완료! ({info.quantity}EA)
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
184
src/components/production/WorkerScreen/IssueReportModal.tsx
Normal file
184
src/components/production/WorkerScreen/IssueReportModal.tsx
Normal file
@@ -0,0 +1,184 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 이슈 보고 모달
|
||||
*
|
||||
* - 이슈 유형 선택 (5개 버튼)
|
||||
* - 상세 내용 textarea
|
||||
* - 벨리데이션 & 성공 다이얼로그
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { AlertTriangle } from 'lucide-react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import type { WorkOrder } from '../ProductionDashboard/types';
|
||||
import type { IssueType } from './types';
|
||||
import { ISSUE_TYPE_LABELS } from './types';
|
||||
|
||||
interface IssueReportModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
order: WorkOrder | null;
|
||||
}
|
||||
|
||||
export function IssueReportModal({ open, onOpenChange, order }: IssueReportModalProps) {
|
||||
const [selectedType, setSelectedType] = useState<IssueType | null>(null);
|
||||
const [description, setDescription] = useState('');
|
||||
const [showValidationAlert, setShowValidationAlert] = useState(false);
|
||||
const [showSuccessAlert, setShowSuccessAlert] = useState(false);
|
||||
|
||||
const issueTypes: IssueType[] = ['defect', 'noStock', 'delay', 'equipment', 'other'];
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!selectedType) {
|
||||
setShowValidationAlert(true);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[이슈보고]', {
|
||||
orderId: order?.id,
|
||||
orderNo: order?.orderNo,
|
||||
issueType: selectedType,
|
||||
description,
|
||||
});
|
||||
|
||||
setShowSuccessAlert(true);
|
||||
};
|
||||
|
||||
const handleSuccessClose = () => {
|
||||
setShowSuccessAlert(false);
|
||||
setSelectedType(null);
|
||||
setDescription('');
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setSelectedType(null);
|
||||
setDescription('');
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
if (!order) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<AlertTriangle className="h-5 w-5 text-orange-500" />
|
||||
이슈 보고
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
<span className="block">작업: {order.orderNo}</span>
|
||||
<span className="block">{order.client}</span>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* 이슈 유형 선택 */}
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-2 block">이슈 유형</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{issueTypes.map((type) => (
|
||||
<Button
|
||||
key={type}
|
||||
variant={selectedType === type ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setSelectedType(type)}
|
||||
className={
|
||||
selectedType === type
|
||||
? 'bg-orange-600 hover:bg-orange-700'
|
||||
: 'hover:bg-orange-50 hover:border-orange-300'
|
||||
}
|
||||
>
|
||||
{ISSUE_TYPE_LABELS[type]}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 상세 내용 */}
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-2 block">상세 내용</label>
|
||||
<Textarea
|
||||
placeholder="이슈 상세 내용을 입력하세요..."
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
rows={4}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={handleCancel}>
|
||||
취소
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} className="bg-orange-600 hover:bg-orange-700">
|
||||
보고
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 벨리데이션 알림 */}
|
||||
<AlertDialog open={showValidationAlert} onOpenChange={setShowValidationAlert}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>알림</AlertDialogTitle>
|
||||
<AlertDialogDescription>이슈 유형을 선택해주세요.</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogAction>확인</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{/* 성공 알림 */}
|
||||
<AlertDialog open={showSuccessAlert} onOpenChange={setShowSuccessAlert}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle className="text-green-600">
|
||||
이슈가 보고되었습니다.
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription asChild>
|
||||
<div className="space-y-2">
|
||||
<p>
|
||||
<span className="text-muted-foreground">작업:</span>{' '}
|
||||
<span className="font-medium text-foreground">{order.orderNo}</span>
|
||||
</p>
|
||||
<p>
|
||||
<span className="text-muted-foreground">유형:</span>{' '}
|
||||
<span className="font-medium text-foreground">
|
||||
{selectedType && ISSUE_TYPE_LABELS[selectedType]}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogAction onClick={handleSuccessClose}>확인</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
244
src/components/production/WorkerScreen/MaterialInputModal.tsx
Normal file
244
src/components/production/WorkerScreen/MaterialInputModal.tsx
Normal file
@@ -0,0 +1,244 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 자재투입 모달
|
||||
*
|
||||
* - FIFO 순위 표시
|
||||
* - 자재 테이블 (BOM 기준)
|
||||
* - 투입 등록 기능
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Package } from 'lucide-react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import type { WorkOrder } from '../ProductionDashboard/types';
|
||||
import type { MaterialInput } from './types';
|
||||
|
||||
// Mock 자재 데이터
|
||||
const MOCK_MATERIALS: MaterialInput[] = [
|
||||
{
|
||||
id: '1',
|
||||
materialCode: 'KD-RM-001',
|
||||
materialName: 'SPHC-SD 1.6T',
|
||||
unit: 'KG',
|
||||
currentStock: 500,
|
||||
fifoRank: 1,
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
materialCode: 'KD-RM-002',
|
||||
materialName: 'EGI 1.55T',
|
||||
unit: 'KG',
|
||||
currentStock: 350,
|
||||
fifoRank: 2,
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
materialCode: 'KD-SM-001',
|
||||
materialName: '볼트 M6x20',
|
||||
unit: 'EA',
|
||||
currentStock: 1200,
|
||||
fifoRank: 3,
|
||||
},
|
||||
];
|
||||
|
||||
interface MaterialInputModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
order: WorkOrder | null;
|
||||
/** 전량완료 흐름에서 사용 - 투입 등록/취소 후 완료 처리 */
|
||||
onComplete?: () => void;
|
||||
/** 전량완료 흐름 여부 (취소 시에도 완료 처리) */
|
||||
isCompletionFlow?: boolean;
|
||||
/** 자재 투입 저장 콜백 */
|
||||
onSaveMaterials?: (orderId: string, materials: MaterialInput[]) => void;
|
||||
/** 이미 투입된 자재 목록 */
|
||||
savedMaterials?: MaterialInput[];
|
||||
}
|
||||
|
||||
export function MaterialInputModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
order,
|
||||
onComplete,
|
||||
isCompletionFlow = false,
|
||||
onSaveMaterials,
|
||||
savedMaterials = [],
|
||||
}: MaterialInputModalProps) {
|
||||
const [selectedMaterials, setSelectedMaterials] = useState<Set<string>>(new Set());
|
||||
const [materials] = useState<MaterialInput[]>(MOCK_MATERIALS);
|
||||
|
||||
// 이미 투입된 자재가 있으면 선택 상태로 초기화
|
||||
const hasSavedMaterials = savedMaterials.length > 0;
|
||||
|
||||
const handleToggleMaterial = (materialId: string) => {
|
||||
setSelectedMaterials((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(materialId)) {
|
||||
next.delete(materialId);
|
||||
} else {
|
||||
next.add(materialId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
// 자재 투입 등록
|
||||
const handleSubmit = () => {
|
||||
if (!order) return;
|
||||
|
||||
// 선택된 자재 정보 추출
|
||||
const selectedMaterialList = materials.filter((m) => selectedMaterials.has(m.id));
|
||||
console.log('[자재투입] 저장:', order.id, selectedMaterialList);
|
||||
|
||||
// 자재 저장 콜백
|
||||
if (onSaveMaterials) {
|
||||
onSaveMaterials(order.id, selectedMaterialList);
|
||||
}
|
||||
|
||||
setSelectedMaterials(new Set());
|
||||
onOpenChange(false);
|
||||
|
||||
// 전량완료 흐름이면 완료 처리
|
||||
if (isCompletionFlow && onComplete) {
|
||||
onComplete();
|
||||
}
|
||||
};
|
||||
|
||||
// 건너뛰기 (자재 없이 완료) - 전량완료 흐름에서만 사용
|
||||
const handleSkip = () => {
|
||||
setSelectedMaterials(new Set());
|
||||
onOpenChange(false);
|
||||
// 전량완료 흐름이면 완료 처리
|
||||
if (onComplete) {
|
||||
onComplete();
|
||||
}
|
||||
};
|
||||
|
||||
// 취소 (모달만 닫기)
|
||||
const handleCancel = () => {
|
||||
setSelectedMaterials(new Set());
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const getFifoRankBadge = (rank: number) => {
|
||||
const colors = {
|
||||
1: 'bg-red-100 text-red-800',
|
||||
2: 'bg-orange-100 text-orange-800',
|
||||
3: 'bg-gray-100 text-gray-800',
|
||||
};
|
||||
const labels = {
|
||||
1: '최우선',
|
||||
2: '차선',
|
||||
3: '대기',
|
||||
};
|
||||
return (
|
||||
<Badge className={colors[rank as 1 | 2 | 3] || colors[3]}>
|
||||
{rank}위 ({labels[rank as 1 | 2 | 3] || labels[3]})
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
if (!order) return null;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Package className="h-5 w-5" />
|
||||
투입자재 등록
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
작업지시 {order.orderNo}에 투입할 자재를 선택하세요.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* FIFO 순위 안내 */}
|
||||
<div className="flex items-center gap-4 text-sm text-muted-foreground">
|
||||
<span>FIFO 순위:</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Badge className="bg-red-100 text-red-800">1</Badge> 최우선
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Badge className="bg-orange-100 text-orange-800">2</Badge> 차선
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Badge className="bg-gray-100 text-gray-800">3+</Badge> 대기
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 자재 테이블 */}
|
||||
{materials.length === 0 ? (
|
||||
<div className="py-8 text-center text-muted-foreground border rounded-lg">
|
||||
이 공정에 배정된 자재가 없습니다.
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-12">선택</TableHead>
|
||||
<TableHead>자재코드</TableHead>
|
||||
<TableHead>자재명</TableHead>
|
||||
<TableHead>단위</TableHead>
|
||||
<TableHead className="text-right">현재고</TableHead>
|
||||
<TableHead>FIFO</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{materials.map((material) => (
|
||||
<TableRow key={material.id}>
|
||||
<TableCell>
|
||||
<Checkbox
|
||||
checked={selectedMaterials.has(material.id)}
|
||||
onCheckedChange={() => handleToggleMaterial(material.id)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="font-medium">{material.materialCode}</TableCell>
|
||||
<TableCell>{material.materialName}</TableCell>
|
||||
<TableCell>{material.unit}</TableCell>
|
||||
<TableCell className="text-right">{material.currentStock.toLocaleString()}</TableCell>
|
||||
<TableCell>{getFifoRankBadge(material.fifoRank)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="flex-col sm:flex-row gap-2">
|
||||
<Button variant="outline" onClick={handleCancel}>
|
||||
취소
|
||||
</Button>
|
||||
{isCompletionFlow && (
|
||||
<Button variant="secondary" onClick={handleSkip}>
|
||||
건너뛰기
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={handleSubmit} disabled={selectedMaterials.size === 0}>
|
||||
투입 등록
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
285
src/components/production/WorkerScreen/ProcessDetailSection.tsx
Normal file
285
src/components/production/WorkerScreen/ProcessDetailSection.tsx
Normal file
@@ -0,0 +1,285 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 공정상세 섹션 컴포넌트
|
||||
*
|
||||
* WorkCard 내부에서 토글 확장되는 공정 상세 정보
|
||||
* - 자재 투입 필요 섹션
|
||||
* - 공정 단계 (5단계)
|
||||
* - 각 단계별 세부 항목
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Package, CheckCircle2, Circle, AlertTriangle, MapPin, Ruler } from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { ProcessStep, ProcessStepItem } from './types';
|
||||
|
||||
// Mock 공정 단계 데이터
|
||||
const MOCK_PROCESS_STEPS: ProcessStep[] = [
|
||||
{
|
||||
id: 'step-1',
|
||||
stepNo: 1,
|
||||
name: '절곡판/코일 절단',
|
||||
completed: 0,
|
||||
total: 2,
|
||||
items: [
|
||||
{
|
||||
id: 'item-1-1',
|
||||
itemNo: '#1',
|
||||
location: '1층 1호-A',
|
||||
isPriority: true,
|
||||
spec: 'W2500 × H3000',
|
||||
material: '절곡판',
|
||||
lot: 'LOT-절곡-2025-001',
|
||||
},
|
||||
{
|
||||
id: 'item-1-2',
|
||||
itemNo: '#2',
|
||||
location: '1층 1호-B',
|
||||
isPriority: false,
|
||||
spec: 'W2000 × H2500',
|
||||
material: '절곡판',
|
||||
lot: 'LOT-절곡-2025-002',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'step-2',
|
||||
stepNo: 2,
|
||||
name: 'V컷팅',
|
||||
completed: 0,
|
||||
total: 2,
|
||||
items: [
|
||||
{
|
||||
id: 'item-2-1',
|
||||
itemNo: '#1',
|
||||
location: '1층 2호-A',
|
||||
isPriority: false,
|
||||
spec: 'V10 × L2500',
|
||||
material: 'V컷팅재',
|
||||
lot: 'LOT-V컷-2025-001',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'step-3',
|
||||
stepNo: 3,
|
||||
name: '절곡',
|
||||
completed: 0,
|
||||
total: 3,
|
||||
items: [
|
||||
{
|
||||
id: 'item-3-1',
|
||||
itemNo: '#1',
|
||||
location: '2층 1호',
|
||||
isPriority: true,
|
||||
spec: '90° × 2회',
|
||||
material: '절곡판',
|
||||
lot: 'LOT-절곡-2025-001',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'step-4',
|
||||
stepNo: 4,
|
||||
name: '중간검사',
|
||||
isInspection: true,
|
||||
completed: 0,
|
||||
total: 1,
|
||||
items: [],
|
||||
},
|
||||
{
|
||||
id: 'step-5',
|
||||
stepNo: 5,
|
||||
name: '포장',
|
||||
completed: 0,
|
||||
total: 1,
|
||||
items: [],
|
||||
},
|
||||
];
|
||||
|
||||
interface ProcessDetailSectionProps {
|
||||
isExpanded: boolean;
|
||||
materialRequired: boolean;
|
||||
onMaterialInput: () => void;
|
||||
}
|
||||
|
||||
export function ProcessDetailSection({
|
||||
isExpanded,
|
||||
materialRequired,
|
||||
onMaterialInput,
|
||||
}: ProcessDetailSectionProps) {
|
||||
const [steps] = useState<ProcessStep[]>(MOCK_PROCESS_STEPS);
|
||||
const [expandedSteps, setExpandedSteps] = useState<Set<string>>(new Set());
|
||||
|
||||
const totalSteps = steps.length;
|
||||
const completedSteps = steps.filter((s) => s.completed === s.total).length;
|
||||
|
||||
const toggleStep = (stepId: string) => {
|
||||
setExpandedSteps((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(stepId)) {
|
||||
next.delete(stepId);
|
||||
} else {
|
||||
next.add(stepId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
if (!isExpanded) return null;
|
||||
|
||||
return (
|
||||
<div className="mt-4 pt-4 border-t space-y-4">
|
||||
{/* 자재 투입 필요 섹션 */}
|
||||
{materialRequired && (
|
||||
<div className="bg-orange-50 border border-orange-200 rounded-lg p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertTriangle className="h-4 w-4 text-orange-500" />
|
||||
<span className="text-sm font-medium text-orange-800">
|
||||
자재 투입이 필요합니다
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={onMaterialInput}
|
||||
className="border-orange-300 text-orange-700 hover:bg-orange-100"
|
||||
>
|
||||
<Package className="mr-1 h-4 w-4" />
|
||||
자재 투입하기
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 공정 단계 헤더 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-sm font-medium">공정 단계</h4>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{completedSteps}/{totalSteps} 완료
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* 공정 단계 목록 */}
|
||||
<div className="space-y-2">
|
||||
{steps.map((step) => (
|
||||
<ProcessStepCard
|
||||
key={step.id}
|
||||
step={step}
|
||||
isExpanded={expandedSteps.has(step.id)}
|
||||
onToggle={() => toggleStep(step.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ===== 하위 컴포넌트 =====
|
||||
|
||||
interface ProcessStepCardProps {
|
||||
step: ProcessStep;
|
||||
isExpanded: boolean;
|
||||
onToggle: () => void;
|
||||
}
|
||||
|
||||
function ProcessStepCard({ step, isExpanded, onToggle }: ProcessStepCardProps) {
|
||||
const isCompleted = step.completed === step.total;
|
||||
const hasItems = step.items.length > 0;
|
||||
|
||||
return (
|
||||
<Collapsible open={isExpanded} onOpenChange={onToggle}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center justify-between p-3 rounded-lg border cursor-pointer transition-colors',
|
||||
isCompleted
|
||||
? 'bg-green-50 border-green-200'
|
||||
: 'bg-gray-50 border-gray-200 hover:bg-gray-100'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
{isCompleted ? (
|
||||
<CheckCircle2 className="h-5 w-5 text-green-500" />
|
||||
) : (
|
||||
<Circle className="h-5 w-5 text-gray-400" />
|
||||
)}
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium">
|
||||
{step.stepNo}. {step.name}
|
||||
</span>
|
||||
{step.isInspection && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
검사
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{step.completed}/{step.total} 완료
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{hasItems && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{isExpanded ? '접기' : '펼치기'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</CollapsibleTrigger>
|
||||
|
||||
{hasItems && (
|
||||
<CollapsibleContent>
|
||||
<div className="ml-8 mt-2 space-y-2">
|
||||
{step.items.map((item) => (
|
||||
<ProcessStepItemCard key={item.id} item={item} />
|
||||
))}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
)}
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
|
||||
interface ProcessStepItemCardProps {
|
||||
item: ProcessStepItem;
|
||||
}
|
||||
|
||||
function ProcessStepItemCard({ item }: ProcessStepItemCardProps) {
|
||||
return (
|
||||
<div className="p-3 bg-white border rounded-lg text-sm">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{item.itemNo}</span>
|
||||
{item.isPriority && (
|
||||
<Badge variant="destructive" className="text-xs">
|
||||
선행 생산
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<Badge variant="outline" className="text-xs font-mono">
|
||||
{item.lot}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2 text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-1">
|
||||
<MapPin className="h-3 w-3" />
|
||||
<span>{item.location}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Ruler className="h-3 w-3" />
|
||||
<span>{item.spec}</span>
|
||||
</div>
|
||||
<div className="col-span-2 flex items-center gap-1">
|
||||
<Package className="h-3 w-3" />
|
||||
<span>자재: {item.material}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
181
src/components/production/WorkerScreen/WorkCard.tsx
Normal file
181
src/components/production/WorkerScreen/WorkCard.tsx
Normal file
@@ -0,0 +1,181 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 작업 카드 컴포넌트
|
||||
*
|
||||
* 각 작업 항목을 카드 형태로 표시
|
||||
* 버튼: 전량완료, 공정상세, 자재투입, 작업일지, 이슈보고
|
||||
* 공정상세 토글 시 ProcessDetailSection 표시
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { CheckCircle, Layers, Package, FileText, AlertTriangle, ChevronDown } from 'lucide-react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import type { WorkOrder } from '../ProductionDashboard/types';
|
||||
import { PROCESS_LABELS, STATUS_LABELS } from '../ProductionDashboard/types';
|
||||
import { ProcessDetailSection } from './ProcessDetailSection';
|
||||
|
||||
interface WorkCardProps {
|
||||
order: WorkOrder;
|
||||
onComplete: (order: WorkOrder) => void;
|
||||
onProcessDetail: (order: WorkOrder) => void;
|
||||
onMaterialInput: (order: WorkOrder) => void;
|
||||
onWorkLog: (order: WorkOrder) => void;
|
||||
onIssueReport: (order: WorkOrder) => void;
|
||||
}
|
||||
|
||||
export function WorkCard({
|
||||
order,
|
||||
onComplete,
|
||||
onProcessDetail,
|
||||
onMaterialInput,
|
||||
onWorkLog,
|
||||
onIssueReport,
|
||||
}: WorkCardProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
// 상태별 배지 스타일
|
||||
const statusBadgeStyle = {
|
||||
waiting: 'bg-gray-100 text-gray-700 border-gray-200',
|
||||
inProgress: 'bg-blue-100 text-blue-700 border-blue-200',
|
||||
completed: 'bg-green-100 text-green-700 border-green-200',
|
||||
};
|
||||
|
||||
// 납기일 포맷 (YYYY. M. D.)
|
||||
const formatDueDate = (dateStr: string) => {
|
||||
const date = new Date(dateStr);
|
||||
return `${date.getFullYear()}. ${date.getMonth() + 1}. ${date.getDate()}.`;
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className={`${order.isUrgent ? 'border-red-200 bg-red-50/30' : 'bg-gray-50/50'} shadow-sm`}>
|
||||
<CardContent className="p-5">
|
||||
{/* 상단: 작업번호 + 상태 + 순위 */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-base font-semibold text-gray-900">
|
||||
{order.orderNo}
|
||||
</span>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`text-xs font-medium rounded-full px-3 py-0.5 ${statusBadgeStyle[order.status]}`}
|
||||
>
|
||||
{STATUS_LABELS[order.status]}
|
||||
</Badge>
|
||||
</div>
|
||||
{order.priority <= 3 && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-sm font-semibold rounded-full px-3 py-1 border-red-400 text-red-500 bg-white"
|
||||
>
|
||||
순위 {order.priority}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 제품명 + 수량 */}
|
||||
<div className="flex items-start justify-between mb-1">
|
||||
<div className="flex-1 space-y-0.5">
|
||||
<h3 className="font-semibold text-lg text-gray-900">{order.productName}</h3>
|
||||
<p className="text-sm text-gray-500">{order.client}</p>
|
||||
<p className="text-sm text-gray-500">{order.projectName}</p>
|
||||
</div>
|
||||
<div className="text-right pl-4">
|
||||
<p className="text-3xl font-bold text-gray-900">{order.quantity}</p>
|
||||
<p className="text-sm text-gray-500">EA</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 공정 + 납기 */}
|
||||
<div className="flex items-center gap-3 mt-4 mb-4">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-sm font-medium rounded-full px-3 py-1 bg-white border-gray-300 text-gray-700"
|
||||
>
|
||||
{PROCESS_LABELS[order.process]}
|
||||
</Badge>
|
||||
<span className="text-sm text-gray-500">
|
||||
납기: {formatDueDate(order.dueDate)}
|
||||
</span>
|
||||
{order.isDelayed && order.delayDays && (
|
||||
<span className="text-sm text-orange-600 font-medium">
|
||||
+{order.delayDays}일 지연
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 지시사항 */}
|
||||
{order.instruction && (
|
||||
<div className="mb-4 p-3 bg-yellow-50 border border-yellow-200 rounded-lg text-sm text-yellow-800">
|
||||
{order.instruction}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 버튼 영역 - 첫 번째 줄 */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => onComplete(order)}
|
||||
className="bg-green-500 hover:bg-green-600 text-white rounded-full px-4 h-9"
|
||||
>
|
||||
<CheckCircle className="mr-1.5 h-4 w-4" />
|
||||
전량완료
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setIsExpanded(!isExpanded);
|
||||
onProcessDetail(order);
|
||||
}}
|
||||
className="rounded-full px-4 h-9 border-gray-300"
|
||||
>
|
||||
<Layers className="mr-1.5 h-4 w-4" />
|
||||
공정상세
|
||||
<ChevronDown className={`ml-1 h-4 w-4 transition-transform ${isExpanded ? 'rotate-180' : ''}`} />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => onMaterialInput(order)}
|
||||
className="rounded-full px-4 h-9 border-gray-300"
|
||||
>
|
||||
<Package className="mr-1.5 h-4 w-4" />
|
||||
자재투입
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => onWorkLog(order)}
|
||||
className="rounded-full px-4 h-9 border-gray-300"
|
||||
>
|
||||
<FileText className="mr-1.5 h-4 w-4" />
|
||||
작업일지
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 버튼 영역 - 두 번째 줄 (이슈보고) */}
|
||||
<div className="mt-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => onIssueReport(order)}
|
||||
className="rounded-full px-4 h-9 border-orange-300 text-orange-500 hover:bg-orange-50 hover:text-orange-600"
|
||||
>
|
||||
<AlertTriangle className="mr-1.5 h-4 w-4" />
|
||||
이슈보고
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 공정상세 섹션 (토글) */}
|
||||
<ProcessDetailSection
|
||||
isExpanded={isExpanded}
|
||||
materialRequired={true}
|
||||
onMaterialInput={() => onMaterialInput(order)}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 작업 완료 결과 다이얼로그
|
||||
*
|
||||
* 스크린샷 기준:
|
||||
* - ✅ 작업이 완료되었습니다
|
||||
* - 🔲 제품검사LOT: KD-SA-251223-01
|
||||
* - ✅ 제품검사(FQC)가 자동 생성되었습니다.
|
||||
* - [품질관리 > 제품검사]에서 검사를 진행하세요.
|
||||
*/
|
||||
|
||||
import { CheckSquare, Square } from 'lucide-react';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
|
||||
interface WorkCompletionResultDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
lotNo: string;
|
||||
onConfirm: () => void;
|
||||
}
|
||||
|
||||
export function WorkCompletionResultDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
lotNo,
|
||||
onConfirm,
|
||||
}: WorkCompletionResultDialogProps) {
|
||||
const handleConfirm = () => {
|
||||
onConfirm();
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
||||
<AlertDialogContent className="bg-zinc-900 text-white border-zinc-700">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle className="sr-only">작업 완료</AlertDialogTitle>
|
||||
<AlertDialogDescription asChild>
|
||||
<div className="space-y-3 text-white">
|
||||
{/* ✅ 작업이 완료되었습니다 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckSquare className="h-5 w-5 text-green-500 fill-green-500" />
|
||||
<span className="text-base">작업이 완료되었습니다.</span>
|
||||
</div>
|
||||
|
||||
{/* 🔲 제품검사LOT */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Square className="h-5 w-5 text-zinc-500" />
|
||||
<span className="text-base text-zinc-400">제품검사LOT: {lotNo}</span>
|
||||
</div>
|
||||
|
||||
{/* ✅ 제품검사(FQC)가 자동 생성되었습니다 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckSquare className="h-5 w-5 text-green-500 fill-green-500" />
|
||||
<span className="text-base">제품검사(FQC)가 자동 생성되었습니다.</span>
|
||||
</div>
|
||||
|
||||
{/* 안내 메시지 */}
|
||||
<p className="text-sm text-zinc-400 pl-7">
|
||||
[품질관리 > 제품검사]에서 검사를 진행하세요.
|
||||
</p>
|
||||
</div>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter className="flex justify-center sm:justify-center">
|
||||
<AlertDialogAction
|
||||
onClick={handleConfirm}
|
||||
className="bg-pink-200 hover:bg-pink-300 text-zinc-900 font-medium px-8"
|
||||
>
|
||||
확인
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
272
src/components/production/WorkerScreen/WorkLogModal.tsx
Normal file
272
src/components/production/WorkerScreen/WorkLogModal.tsx
Normal file
@@ -0,0 +1,272 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 작업일지 모달
|
||||
*
|
||||
* - 헤더: sam-design 작업일지 스타일
|
||||
* - 내부 문서: 스크린샷 기준 작업일지 양식
|
||||
*/
|
||||
|
||||
import { Printer, X } from 'lucide-react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { VisuallyHidden } from '@radix-ui/react-visually-hidden';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import type { WorkOrder } from '../ProductionDashboard/types';
|
||||
import { PROCESS_LABELS } from '../ProductionDashboard/types';
|
||||
|
||||
interface WorkLogModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
order: WorkOrder | null;
|
||||
}
|
||||
|
||||
export function WorkLogModal({ open, onOpenChange, order }: WorkLogModalProps) {
|
||||
const handlePrint = () => {
|
||||
window.print();
|
||||
};
|
||||
|
||||
if (!order) return null;
|
||||
|
||||
const today = new Date().toLocaleDateString('ko-KR', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
}).replace(/\. /g, '-').replace('.', '');
|
||||
|
||||
const documentNo = `WL-${order.process.toUpperCase().slice(0, 3)}`;
|
||||
const lotNo = `KD-TS-${new Date().toISOString().slice(2, 10).replace(/-/g, '')}-01`;
|
||||
|
||||
// 샘플 품목 데이터 (스크린샷 기준)
|
||||
const items = [
|
||||
{ no: 1, name: '스크린 사타 (표준형)', location: '1층/A-01', spec: '3000×2500', qty: 1, status: '대기' },
|
||||
{ no: 2, name: '스크린 사타 (표준형)', location: '2층/A-02', spec: '3000×2500', qty: 1, status: '대기' },
|
||||
{ no: 3, name: '스크린 사타 (표준형)', location: '3층/A-03', spec: '-', qty: '-', status: '대기' },
|
||||
];
|
||||
|
||||
// 작업내역 데이터 (스크린샷 기준)
|
||||
const workStats = {
|
||||
workType: '필름 스크린',
|
||||
workWidth: '1016mm',
|
||||
general: 3,
|
||||
ironing: 3,
|
||||
sandblast: 3,
|
||||
packing: 1,
|
||||
orderQty: 3,
|
||||
completedQty: 1,
|
||||
progress: 33,
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-5xl max-h-[90vh] overflow-y-auto p-0 gap-0 bg-gray-100">
|
||||
{/* 접근성을 위한 숨겨진 타이틀 */}
|
||||
<VisuallyHidden>
|
||||
<DialogTitle>작업일지 - {order.orderNo}</DialogTitle>
|
||||
</VisuallyHidden>
|
||||
{/* 모달 헤더 - sam-design 스타일 */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b bg-white sticky top-0 z-10">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="font-semibold text-lg">작업일지</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{PROCESS_LABELS[order.process]} 생산부서
|
||||
</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
({documentNo})
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={handlePrint}>
|
||||
<Printer className="w-4 h-4 mr-1.5" />
|
||||
인쇄
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 문서 본문 */}
|
||||
<div className="m-6 p-6 bg-white rounded-lg shadow-sm">
|
||||
{/* 문서 헤더: 로고 + 제목 + 결재라인 */}
|
||||
<div className="flex justify-between items-start mb-6 border border-gray-300">
|
||||
{/* 좌측: 로고 영역 */}
|
||||
<div className="w-24 border-r border-gray-300 flex flex-col items-center justify-center p-3">
|
||||
<span className="text-2xl font-bold">KD</span>
|
||||
<span className="text-xs text-gray-500">정동기업</span>
|
||||
</div>
|
||||
|
||||
{/* 중앙: 문서 제목 */}
|
||||
<div className="flex-1 flex flex-col items-center justify-center p-3 border-r border-gray-300">
|
||||
<h1 className="text-xl font-bold tracking-widest mb-1">작 업 일 지</h1>
|
||||
<p className="text-xs text-gray-500">{documentNo}</p>
|
||||
<p className="text-sm font-medium mt-1">{PROCESS_LABELS[order.process]} 생산부서</p>
|
||||
</div>
|
||||
|
||||
{/* 우측: 결재라인 */}
|
||||
<div className="shrink-0 text-xs">
|
||||
{/* 첫 번째 행: 작성/검토/승인 */}
|
||||
<div className="grid grid-cols-3 border-b border-gray-300">
|
||||
<div className="w-16 p-2 text-center font-medium bg-gray-100 border-r border-gray-300">작성</div>
|
||||
<div className="w-16 p-2 text-center font-medium bg-gray-100 border-r border-gray-300">검토</div>
|
||||
<div className="w-16 p-2 text-center font-medium bg-gray-100">승인</div>
|
||||
</div>
|
||||
{/* 두 번째 행: 이름 */}
|
||||
<div className="grid grid-cols-3 border-b border-gray-300">
|
||||
<div className="w-16 p-2 text-center border-r border-gray-300">{order.assignees[0] || '-'}</div>
|
||||
<div className="w-16 p-2 text-center border-r border-gray-300"></div>
|
||||
<div className="w-16 p-2 text-center"></div>
|
||||
</div>
|
||||
{/* 세 번째 행: 부서 */}
|
||||
<div className="grid grid-cols-3">
|
||||
<div className="w-16 p-2 text-center bg-gray-50 border-r border-gray-300">판매</div>
|
||||
<div className="w-16 p-2 text-center bg-gray-50 border-r border-gray-300">생산</div>
|
||||
<div className="w-16 p-2 text-center bg-gray-50">품질</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 기본 정보 테이블 */}
|
||||
<div className="border border-gray-300 mb-6">
|
||||
{/* Row 1 */}
|
||||
<div className="grid grid-cols-2 border-b border-gray-300">
|
||||
<div className="flex border-r border-gray-300">
|
||||
<div className="w-24 bg-gray-100 p-3 text-sm font-medium border-r border-gray-300 flex items-center">
|
||||
발주처
|
||||
</div>
|
||||
<div className="flex-1 p-3 text-sm flex items-center">{order.client}</div>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<div className="w-24 bg-gray-100 p-3 text-sm font-medium border-r border-gray-300 flex items-center">
|
||||
현장명
|
||||
</div>
|
||||
<div className="flex-1 p-3 text-sm flex items-center">{order.projectName}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 2 */}
|
||||
<div className="grid grid-cols-2 border-b border-gray-300">
|
||||
<div className="flex border-r border-gray-300">
|
||||
<div className="w-24 bg-gray-100 p-3 text-sm font-medium border-r border-gray-300 flex items-center">
|
||||
작업일자
|
||||
</div>
|
||||
<div className="flex-1 p-3 text-sm flex items-center">{today}</div>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<div className="w-24 bg-gray-100 p-3 text-sm font-medium border-r border-gray-300 flex items-center">
|
||||
LOT NO.
|
||||
</div>
|
||||
<div className="flex-1 p-3 text-sm flex items-center">{lotNo}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 3 */}
|
||||
<div className="grid grid-cols-2">
|
||||
<div className="flex border-r border-gray-300">
|
||||
<div className="w-24 bg-gray-100 p-3 text-sm font-medium border-r border-gray-300 flex items-center">
|
||||
납기일
|
||||
</div>
|
||||
<div className="flex-1 p-3 text-sm flex items-center">
|
||||
{new Date(order.dueDate).toLocaleDateString('ko-KR', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
}).replace(/\. /g, '-').replace('.', '')}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<div className="w-24 bg-gray-100 p-3 text-sm font-medium border-r border-gray-300 flex items-center">
|
||||
규격
|
||||
</div>
|
||||
<div className="flex-1 p-3 text-sm flex items-center">W- x H-</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 품목 테이블 */}
|
||||
<div className="border border-gray-300 mb-6">
|
||||
{/* 테이블 헤더 */}
|
||||
<div className="grid grid-cols-12 border-b border-gray-300 bg-gray-100">
|
||||
<div className="col-span-1 p-2 text-sm font-medium text-center border-r border-gray-300">No</div>
|
||||
<div className="col-span-4 p-2 text-sm font-medium text-center border-r border-gray-300">품목명</div>
|
||||
<div className="col-span-2 p-2 text-sm font-medium text-center border-r border-gray-300">출/부호</div>
|
||||
<div className="col-span-2 p-2 text-sm font-medium text-center border-r border-gray-300">규격</div>
|
||||
<div className="col-span-1 p-2 text-sm font-medium text-center border-r border-gray-300">수량</div>
|
||||
<div className="col-span-2 p-2 text-sm font-medium text-center">상태</div>
|
||||
</div>
|
||||
|
||||
{/* 테이블 데이터 */}
|
||||
{items.map((item, index) => (
|
||||
<div
|
||||
key={item.no}
|
||||
className={`grid grid-cols-12 ${index < items.length - 1 ? 'border-b border-gray-300' : ''}`}
|
||||
>
|
||||
<div className="col-span-1 p-2 text-sm text-center border-r border-gray-300">{item.no}</div>
|
||||
<div className="col-span-4 p-2 text-sm border-r border-gray-300">{item.name}</div>
|
||||
<div className="col-span-2 p-2 text-sm text-center border-r border-gray-300">{item.location}</div>
|
||||
<div className="col-span-2 p-2 text-sm text-center border-r border-gray-300">{item.spec}</div>
|
||||
<div className="col-span-1 p-2 text-sm text-center border-r border-gray-300">{item.qty}</div>
|
||||
<div className="col-span-2 p-2 text-sm text-center">{item.status}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 작업내역 */}
|
||||
<div className="border border-gray-300 mb-6">
|
||||
{/* 검정 헤더 */}
|
||||
<div className="bg-gray-800 text-white p-2.5 text-sm font-medium text-center">
|
||||
{PROCESS_LABELS[order.process]} 작업내역
|
||||
</div>
|
||||
|
||||
{/* 작업내역 그리드 */}
|
||||
<div className="grid grid-cols-4 border-b border-gray-300">
|
||||
<div className="p-2 text-sm bg-gray-100 border-r border-gray-300 text-center font-medium">화단 유형</div>
|
||||
<div className="p-2 text-sm border-r border-gray-300 text-center">{workStats.workType}</div>
|
||||
<div className="p-2 text-sm bg-gray-100 border-r border-gray-300 text-center font-medium">화단 폭</div>
|
||||
<div className="p-2 text-sm text-center">{workStats.workWidth}</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 border-b border-gray-300">
|
||||
<div className="p-2 text-sm bg-gray-100 border-r border-gray-300 text-center font-medium">화단일반</div>
|
||||
<div className="p-2 text-sm border-r border-gray-300 text-center">{workStats.general} EA</div>
|
||||
<div className="p-2 text-sm bg-gray-100 border-r border-gray-300 text-center font-medium">이싱</div>
|
||||
<div className="p-2 text-sm text-center">{workStats.ironing} EA</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 border-b border-gray-300">
|
||||
<div className="p-2 text-sm bg-gray-100 border-r border-gray-300 text-center font-medium">센드락 작업</div>
|
||||
<div className="p-2 text-sm border-r border-gray-300 text-center">{workStats.sandblast} EA</div>
|
||||
<div className="p-2 text-sm bg-gray-100 border-r border-gray-300 text-center font-medium">포장</div>
|
||||
<div className="p-2 text-sm text-center">{workStats.packing} EA</div>
|
||||
</div>
|
||||
{/* 수량 및 진행률 */}
|
||||
<div className="grid grid-cols-6">
|
||||
<div className="p-2 text-sm bg-gray-100 border-r border-gray-300 text-center font-medium">지시수량</div>
|
||||
<div className="p-2 text-sm border-r border-gray-300 text-center">{workStats.orderQty} EA</div>
|
||||
<div className="p-2 text-sm bg-gray-100 border-r border-gray-300 text-center font-medium">완료수량</div>
|
||||
<div className="p-2 text-sm border-r border-gray-300 text-center">{workStats.completedQty} EA</div>
|
||||
<div className="p-2 text-sm bg-gray-100 border-r border-gray-300 text-center font-medium">진행률</div>
|
||||
<div className="p-2 text-sm text-center font-medium text-blue-600">{workStats.progress}%</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 특이 사항 */}
|
||||
<div className="border border-gray-300">
|
||||
<div className="bg-gray-800 text-white p-2.5 text-sm font-medium text-center">
|
||||
특이사항
|
||||
</div>
|
||||
<div className="p-4 min-h-[60px] text-sm">
|
||||
{order.instruction || '-'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
325
src/components/production/WorkerScreen/index.tsx
Normal file
325
src/components/production/WorkerScreen/index.tsx
Normal file
@@ -0,0 +1,325 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 작업자 화면 메인 컴포넌트
|
||||
*
|
||||
* 기능:
|
||||
* - 상단 통계 카드 4개 (할당/작업중/완료/긴급)
|
||||
* - 내 작업 목록 카드 리스트
|
||||
* - 각 작업 카드별 버튼 (전량완료/공정상세/자재투입/작업일지/이슈보고)
|
||||
*/
|
||||
|
||||
import { useState, useMemo, useCallback } from 'react';
|
||||
import { ClipboardList, PlayCircle, CheckCircle2, AlertTriangle } from 'lucide-react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { PageLayout } from '@/components/organisms/PageLayout';
|
||||
import { generateMockWorkOrders } from '../ProductionDashboard/mockData';
|
||||
import type { WorkOrder } from '../ProductionDashboard/types';
|
||||
import type { WorkerStats, CompletionToastInfo, MaterialInput } from './types';
|
||||
import { WorkCard } from './WorkCard';
|
||||
import { CompletionConfirmDialog } from './CompletionConfirmDialog';
|
||||
import { CompletionToast } from './CompletionToast';
|
||||
import { MaterialInputModal } from './MaterialInputModal';
|
||||
import { WorkLogModal } from './WorkLogModal';
|
||||
import { IssueReportModal } from './IssueReportModal';
|
||||
import { WorkCompletionResultDialog } from './WorkCompletionResultDialog';
|
||||
|
||||
export default function WorkerScreen() {
|
||||
// ===== 상태 관리 =====
|
||||
const [workOrders, setWorkOrders] = useState<WorkOrder[]>(() =>
|
||||
generateMockWorkOrders().filter((o) => o.status !== 'completed')
|
||||
);
|
||||
|
||||
// 모달/다이얼로그 상태
|
||||
const [selectedOrder, setSelectedOrder] = useState<WorkOrder | null>(null);
|
||||
const [isCompletionDialogOpen, setIsCompletionDialogOpen] = useState(false);
|
||||
const [isMaterialModalOpen, setIsMaterialModalOpen] = useState(false);
|
||||
const [isWorkLogModalOpen, setIsWorkLogModalOpen] = useState(false);
|
||||
const [isIssueReportModalOpen, setIsIssueReportModalOpen] = useState(false);
|
||||
|
||||
// 전량완료 흐름 상태
|
||||
const [isCompletionFlow, setIsCompletionFlow] = useState(false);
|
||||
const [isCompletionResultOpen, setIsCompletionResultOpen] = useState(false);
|
||||
const [completionLotNo, setCompletionLotNo] = useState('');
|
||||
|
||||
// 투입된 자재 관리 (orderId -> MaterialInput[])
|
||||
const [inputMaterialsMap, setInputMaterialsMap] = useState<Map<string, MaterialInput[]>>(
|
||||
new Map()
|
||||
);
|
||||
|
||||
// 완료 토스트 상태
|
||||
const [toastInfo, setToastInfo] = useState<CompletionToastInfo | null>(null);
|
||||
|
||||
// 정렬 상태
|
||||
const [sortBy, setSortBy] = useState<'dueDate' | 'latest'>('dueDate');
|
||||
|
||||
// ===== 통계 계산 =====
|
||||
const stats: WorkerStats = useMemo(() => {
|
||||
return {
|
||||
assigned: workOrders.length,
|
||||
inProgress: workOrders.filter((o) => o.status === 'inProgress').length,
|
||||
completed: 0, // 완료된 것은 목록에서 제외되므로 0
|
||||
urgent: workOrders.filter((o) => o.isUrgent).length,
|
||||
};
|
||||
}, [workOrders]);
|
||||
|
||||
// ===== 정렬된 작업 목록 =====
|
||||
const sortedWorkOrders = useMemo(() => {
|
||||
return [...workOrders].sort((a, b) => {
|
||||
if (sortBy === 'dueDate') {
|
||||
// 납기일순 (가까운 날짜 먼저)
|
||||
return new Date(a.dueDate).getTime() - new Date(b.dueDate).getTime();
|
||||
} else {
|
||||
// 최신등록순 (최근 ID가 더 큼 = 최근 등록)
|
||||
return b.id.localeCompare(a.id);
|
||||
}
|
||||
});
|
||||
}, [workOrders, sortBy]);
|
||||
|
||||
// ===== 핸들러 =====
|
||||
|
||||
// 전량완료 버튼 클릭
|
||||
const handleComplete = useCallback(
|
||||
(order: WorkOrder) => {
|
||||
setSelectedOrder(order);
|
||||
|
||||
// 이미 투입된 자재가 있으면 바로 완료 결과 팝업
|
||||
const savedMaterials = inputMaterialsMap.get(order.id);
|
||||
if (savedMaterials && savedMaterials.length > 0) {
|
||||
// LOT 번호 생성
|
||||
const lotNo = `KD-SA-${new Date().toISOString().slice(2, 10).replace(/-/g, '')}-01`;
|
||||
setCompletionLotNo(lotNo);
|
||||
setIsCompletionResultOpen(true);
|
||||
} else {
|
||||
// 자재 투입이 필요합니다 팝업
|
||||
setIsCompletionDialogOpen(true);
|
||||
}
|
||||
},
|
||||
[inputMaterialsMap]
|
||||
);
|
||||
|
||||
// "자재 투입이 필요합니다" 팝업에서 확인 클릭 → MaterialInputModal 열기
|
||||
const handleCompletionConfirm = useCallback(() => {
|
||||
setIsCompletionFlow(true);
|
||||
setIsMaterialModalOpen(true);
|
||||
}, []);
|
||||
|
||||
// MaterialInputModal에서 투입 등록/건너뛰기 후 → 작업 완료 결과 팝업 표시
|
||||
const handleWorkCompletion = useCallback(() => {
|
||||
if (!selectedOrder) return;
|
||||
|
||||
// LOT 번호 생성
|
||||
const lotNo = `KD-SA-${new Date().toISOString().slice(2, 10).replace(/-/g, '')}-01`;
|
||||
setCompletionLotNo(lotNo);
|
||||
|
||||
// 완료 결과 팝업 표시
|
||||
setIsCompletionResultOpen(true);
|
||||
setIsCompletionFlow(false);
|
||||
}, [selectedOrder]);
|
||||
|
||||
// 자재 저장 핸들러
|
||||
const handleSaveMaterials = useCallback((orderId: string, materials: MaterialInput[]) => {
|
||||
setInputMaterialsMap((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(orderId, materials);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// 완료 결과 팝업에서 확인 → 목록에서 제거
|
||||
const handleCompletionResultConfirm = useCallback(() => {
|
||||
if (!selectedOrder) return;
|
||||
|
||||
// 투입된 자재 맵에서도 제거
|
||||
setInputMaterialsMap((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.delete(selectedOrder.id);
|
||||
return next;
|
||||
});
|
||||
|
||||
// 목록에서 제거
|
||||
setWorkOrders((prev) => prev.filter((o) => o.id !== selectedOrder.id));
|
||||
setSelectedOrder(null);
|
||||
setCompletionLotNo('');
|
||||
}, [selectedOrder]);
|
||||
|
||||
const handleProcessDetail = useCallback((order: WorkOrder) => {
|
||||
setSelectedOrder(order);
|
||||
// 공정상세는 카드 내 토글로 처리 (Phase 4에서 구현)
|
||||
console.log('[공정상세] 토글:', order.orderNo);
|
||||
}, []);
|
||||
|
||||
const handleMaterialInput = useCallback((order: WorkOrder) => {
|
||||
setSelectedOrder(order);
|
||||
setIsMaterialModalOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleWorkLog = useCallback((order: WorkOrder) => {
|
||||
setSelectedOrder(order);
|
||||
setIsWorkLogModalOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleIssueReport = useCallback((order: WorkOrder) => {
|
||||
setSelectedOrder(order);
|
||||
setIsIssueReportModalOpen(true);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
<div className="space-y-6">
|
||||
{/* 완료 토스트 */}
|
||||
{toastInfo && <CompletionToast info={toastInfo} />}
|
||||
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
|
||||
<ClipboardList className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">작업자 화면</h1>
|
||||
<p className="text-sm text-muted-foreground">내 작업 목록을 확인하고 관리합니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 통계 카드 */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<StatCard
|
||||
title="할일"
|
||||
value={stats.assigned}
|
||||
icon={<ClipboardList className="h-4 w-4" />}
|
||||
variant="default"
|
||||
/>
|
||||
<StatCard
|
||||
title="작업중"
|
||||
value={stats.inProgress}
|
||||
icon={<PlayCircle className="h-4 w-4" />}
|
||||
variant="blue"
|
||||
/>
|
||||
<StatCard
|
||||
title="완료"
|
||||
value={stats.completed}
|
||||
icon={<CheckCircle2 className="h-4 w-4" />}
|
||||
variant="green"
|
||||
/>
|
||||
<StatCard
|
||||
title="긴급"
|
||||
value={stats.urgent}
|
||||
icon={<AlertTriangle className="h-4 w-4" />}
|
||||
variant="red"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 작업 목록 */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold">내 작업 목록</h2>
|
||||
<Select value={sortBy} onValueChange={(value: 'dueDate' | 'latest') => setSortBy(value)}>
|
||||
<SelectTrigger className="w-[140px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="dueDate">납기일순</SelectItem>
|
||||
<SelectItem value="latest">최신등록순</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{sortedWorkOrders.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="py-12 text-center text-muted-foreground">
|
||||
배정된 작업이 없습니다.
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{sortedWorkOrders.map((order) => (
|
||||
<WorkCard
|
||||
key={order.id}
|
||||
order={order}
|
||||
onComplete={handleComplete}
|
||||
onProcessDetail={handleProcessDetail}
|
||||
onMaterialInput={handleMaterialInput}
|
||||
onWorkLog={handleWorkLog}
|
||||
onIssueReport={handleIssueReport}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 모달/다이얼로그 */}
|
||||
<CompletionConfirmDialog
|
||||
open={isCompletionDialogOpen}
|
||||
onOpenChange={setIsCompletionDialogOpen}
|
||||
order={selectedOrder}
|
||||
onConfirm={handleCompletionConfirm}
|
||||
/>
|
||||
|
||||
<MaterialInputModal
|
||||
open={isMaterialModalOpen}
|
||||
onOpenChange={setIsMaterialModalOpen}
|
||||
order={selectedOrder}
|
||||
isCompletionFlow={isCompletionFlow}
|
||||
onComplete={handleWorkCompletion}
|
||||
onSaveMaterials={handleSaveMaterials}
|
||||
savedMaterials={selectedOrder ? inputMaterialsMap.get(selectedOrder.id) : undefined}
|
||||
/>
|
||||
|
||||
<WorkLogModal
|
||||
open={isWorkLogModalOpen}
|
||||
onOpenChange={setIsWorkLogModalOpen}
|
||||
order={selectedOrder}
|
||||
/>
|
||||
|
||||
<IssueReportModal
|
||||
open={isIssueReportModalOpen}
|
||||
onOpenChange={setIsIssueReportModalOpen}
|
||||
order={selectedOrder}
|
||||
/>
|
||||
|
||||
<WorkCompletionResultDialog
|
||||
open={isCompletionResultOpen}
|
||||
onOpenChange={setIsCompletionResultOpen}
|
||||
lotNo={completionLotNo}
|
||||
onConfirm={handleCompletionResultConfirm}
|
||||
/>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
// ===== 하위 컴포넌트 =====
|
||||
|
||||
interface StatCardProps {
|
||||
title: string;
|
||||
value: number;
|
||||
icon: React.ReactNode;
|
||||
variant: 'default' | 'blue' | 'green' | 'red';
|
||||
}
|
||||
|
||||
function StatCard({ title, value, icon, variant }: StatCardProps) {
|
||||
const variantClasses = {
|
||||
default: 'bg-gray-50 text-gray-700',
|
||||
blue: 'bg-blue-50 text-blue-700',
|
||||
green: 'bg-green-50 text-green-700',
|
||||
red: 'bg-red-50 text-red-700',
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className={variantClasses[variant]}>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium">{title}</span>
|
||||
{icon}
|
||||
</div>
|
||||
<p className="text-2xl font-bold mt-2">{value}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
74
src/components/production/WorkerScreen/types.ts
Normal file
74
src/components/production/WorkerScreen/types.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
// 작업자 화면 타입 정의
|
||||
|
||||
import type { WorkOrder, ProcessType } from '../ProductionDashboard/types';
|
||||
|
||||
// 작업자 작업 아이템 (WorkOrder 확장)
|
||||
export interface WorkerWorkItem extends WorkOrder {
|
||||
processDetail?: ProcessDetail;
|
||||
}
|
||||
|
||||
// 공정상세 정보
|
||||
export interface ProcessDetail {
|
||||
materialRequired: boolean;
|
||||
steps: ProcessStep[];
|
||||
totalSteps: number;
|
||||
completedSteps: number;
|
||||
}
|
||||
|
||||
// 공정 단계
|
||||
export interface ProcessStep {
|
||||
id: string;
|
||||
stepNo: number;
|
||||
name: string;
|
||||
isInspection?: boolean;
|
||||
completed: number;
|
||||
total: number;
|
||||
items: ProcessStepItem[];
|
||||
}
|
||||
|
||||
// 공정 단계 상세 항목
|
||||
export interface ProcessStepItem {
|
||||
id: string;
|
||||
itemNo: string; // #1, #2
|
||||
location: string; // 1층 1호-A
|
||||
isPriority: boolean; // 선행 생산
|
||||
spec: string; // W2500 × H3000
|
||||
material: string; // 자재: 절곡판
|
||||
lot: string; // LOT-절곡-2025-001
|
||||
}
|
||||
|
||||
// 자재 투입 정보
|
||||
export interface MaterialInput {
|
||||
id: string;
|
||||
materialCode: string;
|
||||
materialName: string;
|
||||
unit: string;
|
||||
currentStock: number;
|
||||
fifoRank: number; // FIFO 순위 (1: 최우선, 2: 차선, 3+: 대기)
|
||||
}
|
||||
|
||||
// 이슈 유형
|
||||
export type IssueType = 'defect' | 'noStock' | 'delay' | 'equipment' | 'other';
|
||||
|
||||
export const ISSUE_TYPE_LABELS: Record<IssueType, string> = {
|
||||
defect: '불량품 발생',
|
||||
noStock: '재고 없음',
|
||||
delay: '일정 지연',
|
||||
equipment: '설비 문제',
|
||||
other: '기타',
|
||||
};
|
||||
|
||||
// 작업자 화면 통계
|
||||
export interface WorkerStats {
|
||||
assigned: number;
|
||||
inProgress: number;
|
||||
completed: number;
|
||||
urgent: number;
|
||||
}
|
||||
|
||||
// 완료 토스트 정보
|
||||
export interface CompletionToastInfo {
|
||||
orderNo: string;
|
||||
quantity: number;
|
||||
lotNo: string;
|
||||
}
|
||||
320
src/components/quality/InspectionManagement/InspectionCreate.tsx
Normal file
320
src/components/quality/InspectionManagement/InspectionCreate.tsx
Normal file
@@ -0,0 +1,320 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 검사 등록 페이지
|
||||
*/
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { ClipboardCheck, ImageIcon } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { PageLayout } from '@/components/organisms/PageLayout';
|
||||
import { inspectionItemsTemplate, judgeMeasurement } from './mockData';
|
||||
import type { InspectionItem, QualityCheckItem, MeasurementItem } from './types';
|
||||
|
||||
export function InspectionCreate() {
|
||||
const router = useRouter();
|
||||
|
||||
// 폼 상태
|
||||
const [formData, setFormData] = useState({
|
||||
lotNo: 'WO-251219-05', // 자동 (예시)
|
||||
itemName: '조인트바', // 자동 (예시)
|
||||
processName: '조립 공정', // 자동 (예시)
|
||||
quantity: 50,
|
||||
inspector: '',
|
||||
remarks: '',
|
||||
});
|
||||
|
||||
// 검사 항목 상태
|
||||
const [inspectionItems, setInspectionItems] = useState<InspectionItem[]>(
|
||||
inspectionItemsTemplate.map(item => ({ ...item }))
|
||||
);
|
||||
|
||||
// validation 에러 상태
|
||||
const [validationErrors, setValidationErrors] = useState<string[]>([]);
|
||||
|
||||
// 폼 입력 핸들러
|
||||
const handleInputChange = (field: string, value: string | number) => {
|
||||
setFormData(prev => ({ ...prev, [field]: value }));
|
||||
// 입력 시 에러 클리어
|
||||
if (validationErrors.length > 0) {
|
||||
setValidationErrors([]);
|
||||
}
|
||||
};
|
||||
|
||||
// 품질 검사 항목 결과 변경 (양호/불량)
|
||||
const handleQualityResultChange = useCallback((itemId: string, result: '양호' | '불량') => {
|
||||
setInspectionItems(prev => prev.map(item => {
|
||||
if (item.id === itemId && item.type === 'quality') {
|
||||
return {
|
||||
...item,
|
||||
result,
|
||||
judgment: result === '양호' ? '적합' : '부적합',
|
||||
} as QualityCheckItem;
|
||||
}
|
||||
return item;
|
||||
}));
|
||||
// 입력 시 에러 클리어
|
||||
setValidationErrors([]);
|
||||
}, []);
|
||||
|
||||
// 측정 항목 값 변경
|
||||
const handleMeasurementChange = useCallback((itemId: string, value: string) => {
|
||||
setInspectionItems(prev => prev.map(item => {
|
||||
if (item.id === itemId && item.type === 'measurement') {
|
||||
const measuredValue = parseFloat(value) || 0;
|
||||
const judgment = judgeMeasurement(item.spec, measuredValue);
|
||||
return {
|
||||
...item,
|
||||
measuredValue,
|
||||
judgment,
|
||||
} as MeasurementItem;
|
||||
}
|
||||
return item;
|
||||
}));
|
||||
// 입력 시 에러 클리어
|
||||
setValidationErrors([]);
|
||||
}, []);
|
||||
|
||||
// 취소
|
||||
const handleCancel = () => {
|
||||
router.push('/quality/inspections');
|
||||
};
|
||||
|
||||
// validation 체크
|
||||
const validateForm = (): boolean => {
|
||||
const errors: string[] = [];
|
||||
|
||||
// 필수 필드: 작업자
|
||||
if (!formData.inspector.trim()) {
|
||||
errors.push('작업자는 필수 입력 항목입니다.');
|
||||
}
|
||||
|
||||
// 검사 항목 validation
|
||||
inspectionItems.forEach((item, index) => {
|
||||
if (item.type === 'quality') {
|
||||
const qualityItem = item as QualityCheckItem;
|
||||
if (!qualityItem.result) {
|
||||
errors.push(`${index + 1}. ${item.name}: 결과를 선택해주세요.`);
|
||||
}
|
||||
} else if (item.type === 'measurement') {
|
||||
const measurementItem = item as MeasurementItem;
|
||||
if (measurementItem.measuredValue === undefined || measurementItem.measuredValue === null) {
|
||||
errors.push(`${index + 1}. ${item.name}: 측정값을 입력해주세요.`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
setValidationErrors(errors);
|
||||
return errors.length === 0;
|
||||
};
|
||||
|
||||
// 검사완료
|
||||
const handleSubmit = () => {
|
||||
// validation 체크
|
||||
if (!validateForm()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// TODO: API 호출
|
||||
console.log('Submit:', { ...formData, items: inspectionItems });
|
||||
router.push('/quality/inspections');
|
||||
};
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
<div className="space-y-6">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<ClipboardCheck className="w-6 h-6" />
|
||||
<h1 className="text-xl font-semibold">검사 등록</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" onClick={handleCancel}>
|
||||
취소
|
||||
</Button>
|
||||
<Button onClick={handleSubmit}>
|
||||
검사완료
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Validation 에러 표시 */}
|
||||
{validationErrors.length > 0 && (
|
||||
<Alert className="bg-red-50 border-red-200">
|
||||
<AlertDescription className="text-red-900">
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="text-lg">⚠️</span>
|
||||
<div className="flex-1">
|
||||
<strong className="block mb-2">
|
||||
입력 내용을 확인해주세요 ({validationErrors.length}개 오류)
|
||||
</strong>
|
||||
<ul className="space-y-1 text-sm">
|
||||
{validationErrors.map((error, index) => (
|
||||
<li key={index} className="flex items-start gap-1">
|
||||
<span>•</span>
|
||||
<span>{error}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* 검사 개요 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">검사 개요</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 className="text-muted-foreground">LOT NO (자동)</Label>
|
||||
<Input
|
||||
value={formData.lotNo}
|
||||
disabled
|
||||
className="bg-muted"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-muted-foreground">품목명 (자동)</Label>
|
||||
<Input
|
||||
value={formData.itemName}
|
||||
disabled
|
||||
className="bg-muted"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-muted-foreground">공정명 (자동)</Label>
|
||||
<Input
|
||||
value={formData.processName}
|
||||
disabled
|
||||
className="bg-muted"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>수량</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={formData.quantity}
|
||||
onChange={(e) => handleInputChange('quantity', parseInt(e.target.value) || 0)}
|
||||
placeholder="수량 입력"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>작업자 *</Label>
|
||||
<Input
|
||||
value={formData.inspector}
|
||||
onChange={(e) => handleInputChange('inspector', e.target.value)}
|
||||
placeholder="작업자 입력"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2 md:col-span-3">
|
||||
<Label>특이사항</Label>
|
||||
<Input
|
||||
value={formData.remarks}
|
||||
onChange={(e) => handleInputChange('remarks', e.target.value)}
|
||||
placeholder="특이사항 입력"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 검사 기준 및 도해 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">검사 기준 및 도해</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-center h-48 bg-muted rounded-lg border-2 border-dashed">
|
||||
<div className="text-center text-muted-foreground">
|
||||
<ImageIcon className="w-12 h-12 mx-auto mb-2 opacity-50" />
|
||||
<p>템플릿에서 설정한 조인트바 표준 도면이 표시됨</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 검사 데이터 입력 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">검사 데이터 입력</CardTitle>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
* 측정값을 입력하면 판정이 자동 처리됩니다.
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{inspectionItems.map((item, index) => (
|
||||
<div key={item.id} className="p-4 border rounded-lg">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h4 className="font-medium">
|
||||
{index + 1}. {item.name}
|
||||
{item.type === 'measurement' && ` (${(item as MeasurementItem).unit})`}
|
||||
</h4>
|
||||
<span className={`text-sm font-medium ${
|
||||
item.judgment === '적합' ? 'text-green-600' :
|
||||
item.judgment === '부적합' ? 'text-red-600' :
|
||||
'text-muted-foreground'
|
||||
}`}>
|
||||
판정: {item.judgment || '-'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-muted-foreground">기준(Spec)</Label>
|
||||
<Input
|
||||
value={item.spec}
|
||||
disabled
|
||||
className="bg-muted"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{item.type === 'quality' ? (
|
||||
<div className="space-y-2">
|
||||
<Label>결과 입력 *</Label>
|
||||
<RadioGroup
|
||||
value={(item as QualityCheckItem).result || ''}
|
||||
onValueChange={(value) => handleQualityResultChange(item.id, value as '양호' | '불량')}
|
||||
className="flex items-center gap-4 h-10"
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="양호" id={`${item.id}-pass`} />
|
||||
<Label htmlFor={`${item.id}-pass`} className="cursor-pointer">양호</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="불량" id={`${item.id}-fail`} />
|
||||
<Label htmlFor={`${item.id}-fail`} className="cursor-pointer">불량</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<Label>측정값 입력 ({(item as MeasurementItem).unit}) *</Label>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.1"
|
||||
value={(item as MeasurementItem).measuredValue || ''}
|
||||
onChange={(e) => handleMeasurementChange(item.id, e.target.value)}
|
||||
placeholder={`측정값 입력 (${(item as MeasurementItem).unit})`}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user