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:
byeongcheolryu
2025-12-23 21:13:07 +09:00
parent 2ebcea0255
commit f0e8e51d06
108 changed files with 21156 additions and 84 deletions

View File

@@ -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 (출고관리 출하관리 페이지 추가)

View File

@@ -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 직접 파싱 금지) |

View File

@@ -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 | 최초 작성 - 공통화 후보 분석 |

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

View File

@@ -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`
- [ ] 작업지시 관리 페이지 생성 후 네비게이션 연결

View File

@@ -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
**상태**: 🔄 작업일지 모달 개선 대기

View File

@@ -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 | 타입체크 통과, 문서 업데이트 | ✅ |

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

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

View File

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

View File

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

View File

@@ -0,0 +1,5 @@
import { InspectionCreate } from '@/components/material/ReceivingManagement';
export default function InspectionPage() {
return <InspectionCreate />;
}

View File

@@ -0,0 +1,5 @@
import { ReceivingList } from '@/components/material/ReceivingManagement';
export default function ReceivingManagementPage() {
return <ReceivingList />;
}

View File

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

View File

@@ -0,0 +1,5 @@
import { StockStatusList } from '@/components/material/StockStatus';
export default function StockStatusPage() {
return <StockStatusList />;
}

View File

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

View File

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

View File

@@ -0,0 +1,10 @@
/**
* 출하관리 - 등록 페이지
* URL: /outbound/shipments/new
*/
import { ShipmentCreate } from '@/components/outbound/ShipmentManagement';
export default function NewShipmentPage() {
return <ShipmentCreate />;
}

View File

@@ -0,0 +1,10 @@
/**
* 출하관리 - 목록 페이지
* URL: /outbound/shipments
*/
import { ShipmentList } from '@/components/outbound/ShipmentManagement';
export default function ShipmentsPage() {
return <ShipmentList />;
}

View 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: '공장별 작업 현황을 확인합니다.',
};

View File

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

View File

@@ -0,0 +1,10 @@
/**
* 작업지시 등록 페이지
* URL: /production/work-orders/create
*/
import { WorkOrderCreate } from '@/components/production/WorkOrders';
export default function WorkOrderCreatePage() {
return <WorkOrderCreate />;
}

View File

@@ -0,0 +1,10 @@
/**
* 작업지시 목록 페이지
* URL: /production/work-orders
*/
import { WorkOrderList } from '@/components/production/WorkOrders';
export default function WorkOrdersPage() {
return <WorkOrderList />;
}

View File

@@ -0,0 +1,10 @@
/**
* 작업실적 조회 페이지
* URL: /production/work-results
*/
import { WorkResultList } from '@/components/production/WorkResults';
export default function WorkResultsPage() {
return <WorkResultList />;
}

View File

@@ -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: '내 작업 목록을 확인하고 관리합니다.',
};

View File

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

View File

@@ -0,0 +1,10 @@
/**
* 검사 등록 페이지
* URL: /quality/inspections/new
*/
import { InspectionCreate } from '@/components/quality/InspectionManagement';
export default function InspectionNewPage() {
return <InspectionCreate />;
}

View File

@@ -0,0 +1,10 @@
/**
* 검사 목록 페이지
* URL: /quality/inspections
*/
import { InspectionList } from '@/components/quality/InspectionManagement';
export default function InspectionsPage() {
return <InspectionList />;
}

View File

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

View File

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

View File

@@ -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">
&gt; .
</p>
</div>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogAction onClick={handleSuccessDialogClose}>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</PageLayout>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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="기본 정보"

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

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

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

View File

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

View File

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

View File

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

View 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';

View 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}`;
}

View 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;
}

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

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

View File

@@ -0,0 +1,3 @@
export { StockStatusList } from './StockStatusList';
export { StockStatusDetail } from './StockStatusDetail';
export * from './types';

View 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();

View 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;
}

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

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

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

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

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

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

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

View 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";

View 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";

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

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

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

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

View File

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

View File

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

View File

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

View 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';

View 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: '물류사' },
];

View 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;
}

View File

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

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

View 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 },
];
};

View 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',
};

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

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

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

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

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

View 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';

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

View 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;
}

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

View File

@@ -0,0 +1,6 @@
/**
* 작업실적 조회 컴포넌트 exports
*/
export { WorkResultList } from './WorkResultList';
export type { WorkResult, WorkResultStats } from './types';

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

View 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; // 불량률 (%)
}

View File

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

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

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

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

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

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

View File

@@ -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">
[ &gt; ] .
</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>
);
}

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

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

View 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;
}

View 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