Merge branch 'master' into feature/universal-list-component
This commit is contained in:
468
CURRENT_WORKS.md
468
CURRENT_WORKS.md
@@ -1,6 +1,390 @@
|
||||
# SAM React 작업 현황
|
||||
|
||||
## 2026-01-02 (목) - 견적 등록 자동산출 기능 구현
|
||||
## 2026-01-09 (목) - Phase L 건설관리 Mock → API 연동 (3개 모듈) ✅
|
||||
|
||||
### 작업 목표
|
||||
- Backend API가 이미 존재하는 3개 모듈의 Mock → API 연동
|
||||
- pricing-management, estimates, category-management
|
||||
|
||||
### 완료된 작업
|
||||
|
||||
| 모듈 | 변경 내용 | 상태 |
|
||||
|------|----------|------|
|
||||
| pricing-management | Mock → apiClient 변환 (378줄), types.ts 타입 추가 | ✅ |
|
||||
| estimates | Mock → apiClient 변환, 복잡한 중첩 타입 처리 | ✅ |
|
||||
| category-management | Mock → apiClient 변환, 에러 타입 처리 (IN_USE/DEFAULT/GENERAL) | ✅ |
|
||||
|
||||
### 수정된 파일
|
||||
| 파일명 | 설명 |
|
||||
|--------|------|
|
||||
| `src/components/business/construction/pricing-management/actions.ts` | Mock → apiClient 표준화 |
|
||||
| `src/components/business/construction/pricing-management/types.ts` | PricingListResponse, PricingFilter, PricingFormData 추가 |
|
||||
| `src/components/business/construction/estimates/actions.ts` | Mock → apiClient 표준화 (중첩 타입) |
|
||||
| `src/components/business/construction/category-management/actions.ts` | Mock → apiClient 표준화 |
|
||||
|
||||
### 적용된 패턴
|
||||
- `'use server'` + `apiClient from '@/lib/api'`
|
||||
- Snake_case API 타입 (ApiXxx) → camelCase Frontend 타입 변환
|
||||
- 표준 응답: `{ success, data?, error? }`
|
||||
- 페이지네이션: `{ items, total, page, size, totalPages }`
|
||||
|
||||
### 빌드 검증
|
||||
✅ Next.js 빌드 성공 (349 페이지)
|
||||
|
||||
### 남은 Mock 모듈 (Backend API 개발 필요)
|
||||
| 모듈 | Backend API | 비고 |
|
||||
|------|-------------|------|
|
||||
| bidding | ❌ 없음 | Backend 필요 |
|
||||
| site-briefings | ❌ 없음 | Backend 필요 |
|
||||
| structure-review | ❌ 없음 | Backend 필요 |
|
||||
| labor-management | ❌ 없음 | Backend 필요 |
|
||||
|
||||
---
|
||||
|
||||
## 2026-01-09 (목) - Phase 1.3-1.5 건설관리 apiClient 표준화
|
||||
|
||||
### 작업 목표
|
||||
- 건설관리 모듈의 커스텀 `apiRequest` 함수를 표준 `apiClient` 패턴으로 변환
|
||||
- Phase 1.3: 계약관리(contract), Phase 1.4: 거래처관리(partners), Phase 1.5: 현장관리(site-management)
|
||||
|
||||
### 수정된 파일
|
||||
| 파일명 | 설명 |
|
||||
|--------|------|
|
||||
| `src/components/business/construction/contract/actions.ts` | 커스텀 apiRequest → apiClient 표준화 |
|
||||
| `src/components/business/construction/partners/actions.ts` | 커스텀 apiRequest → apiClient 표준화 |
|
||||
| `src/components/business/construction/site-management/actions.ts` | 커스텀 apiRequest → apiClient 표준화 |
|
||||
|
||||
### 주요 변경 내용
|
||||
|
||||
#### 1. 제거된 코드 (각 파일에서)
|
||||
- 커스텀 `apiRequest()` 함수 전체
|
||||
- `import { cookies } from 'next/headers'`
|
||||
- `const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL`
|
||||
- `const API_KEY = process.env.API_KEY`
|
||||
|
||||
#### 2. 추가된 코드
|
||||
- `import { apiClient } from '@/lib/api'`
|
||||
- 명시적 API 타입 정의:
|
||||
- **contract**: `ApiContract`, `ApiContractFile`, `ApiAttachment`, `ApiContractStats`, `ApiContractStageCount`
|
||||
- **partners**: `ApiPartner`, `ApiPartnerStats`
|
||||
- **site-management**: `ApiSite`, `ApiSiteStats`
|
||||
|
||||
#### 3. API 엔드포인트 (변경 없음)
|
||||
**계약관리 (contract)**
|
||||
- `GET /construction/contracts` - 목록
|
||||
- `GET /construction/contracts/stats` - 통계
|
||||
- `GET /construction/contracts/stage-counts` - 단계별 건수
|
||||
- `GET /construction/contracts/{id}` - 상세
|
||||
- `POST /construction/contracts` - 등록
|
||||
- `PUT /construction/contracts/{id}` - 수정
|
||||
- `DELETE /construction/contracts/{id}` - 삭제
|
||||
- `DELETE /construction/contracts/bulk` - 일괄 삭제
|
||||
|
||||
**거래처관리 (partners)**
|
||||
- `GET /clients` - 목록
|
||||
- `GET /clients/stats` - 통계
|
||||
- `GET /clients/{id}` - 상세
|
||||
- `POST /clients` - 등록
|
||||
- `PUT /clients/{id}` - 수정
|
||||
- `DELETE /clients/{id}` - 삭제
|
||||
- `DELETE /clients/bulk` - 일괄 삭제
|
||||
|
||||
**현장관리 (site-management)**
|
||||
- `GET /sites` - 목록
|
||||
- `GET /sites/stats` - 통계
|
||||
- `DELETE /sites/{id}` - 삭제
|
||||
- `DELETE /sites/bulk` - 일괄 삭제
|
||||
|
||||
### 빌드 검증
|
||||
✅ Next.js 빌드 성공 (349 페이지)
|
||||
|
||||
### Git 커밋
|
||||
- React: `5db6e59` refactor(construction): 건설관리 3개 모듈 apiClient 표준화
|
||||
|
||||
---
|
||||
|
||||
## 2026-01-09 (목) - Phase 1.2 인수인계보고서 API 표준화
|
||||
|
||||
### 작업 목표
|
||||
- `handover-report/actions.ts` 커스텀 fetch → 표준 apiClient 변환
|
||||
- 기존 API 연동 코드를 프로젝트 표준 패턴으로 통일
|
||||
|
||||
### 수정된 파일
|
||||
| 파일명 | 설명 |
|
||||
|--------|------|
|
||||
| `src/components/business/construction/handover-report/actions.ts` | 커스텀 apiRequest → apiClient 표준화 |
|
||||
|
||||
### 주요 변경 내용
|
||||
|
||||
#### 1. 제거된 코드
|
||||
- 커스텀 `apiRequest()` 함수 (52줄)
|
||||
- `cookies()` 직접 import
|
||||
- `API_BASE_URL`, `API_KEY` 직접 정의
|
||||
|
||||
#### 2. 추가된 코드
|
||||
- `import { apiClient } from '@/lib/api'`
|
||||
- 명시적 API 타입 정의: `ApiHandoverReport`, `ApiManager`, `ApiContractItem`, `ApiExternalEquipmentCost`
|
||||
|
||||
#### 3. API 엔드포인트 (변경 없음)
|
||||
- `GET /construction/handover-reports` - 목록
|
||||
- `GET /construction/handover-reports/stats` - 통계
|
||||
- `GET /construction/handover-reports/{id}` - 상세
|
||||
- `POST /construction/handover-reports` - 등록
|
||||
- `PUT /construction/handover-reports/{id}` - 수정
|
||||
- `DELETE /construction/handover-reports/{id}` - 삭제
|
||||
- `DELETE /construction/handover-reports/bulk` - 일괄 삭제
|
||||
|
||||
### 빌드 검증
|
||||
✅ Next.js 빌드 성공 (349 페이지)
|
||||
|
||||
### Git 커밋
|
||||
- React: `b7b8b90` refactor(handover-report): 커스텀 fetch → apiClient 표준화
|
||||
|
||||
---
|
||||
|
||||
## 2026-01-09 (목) - Phase 2.4 수주관리 API 연동
|
||||
|
||||
### 작업 목표
|
||||
- 시공사 페이지 API 연동 계획 Phase 2.4: 수주관리
|
||||
- `order-management/actions.ts` Mock 데이터 → 실제 API 연동
|
||||
- common_codes 테이블 기반 공용 코드 시스템 도입
|
||||
|
||||
### 수정된 파일
|
||||
| 저장소 | 파일명 | 설명 |
|
||||
|--------|--------|------|
|
||||
| api | `database/migrations/2026_01_09_171700_add_order_codes_to_common_codes.php` | order_status/order_type 코드 추가 |
|
||||
| api | `app/Http/Controllers/Api/V1/CommonController.php` | index 메서드 구현 |
|
||||
| react | `src/lib/api/common-codes.ts` | 공용 코드 조회 유틸리티 (신규) |
|
||||
| react | `src/lib/api/index.ts` | common-codes 모듈 export 추가 |
|
||||
| react | `src/components/business/construction/order-management/actions.ts` | Mock → API 완전 재작성 |
|
||||
|
||||
### 주요 변경 내용
|
||||
|
||||
#### 1. common_codes 공용 코드 시스템
|
||||
- `order_status` 코드 그룹: DRAFT, CONFIRMED, IN_PROGRESS, COMPLETED, CANCELLED
|
||||
- `order_type` 코드 그룹: ORDER, PURCHASE
|
||||
- API 엔드포인트: `GET /api/v1/settings/common/{group}`
|
||||
|
||||
#### 2. 상태 매핑 함수
|
||||
| Frontend | Backend |
|
||||
|----------|---------|
|
||||
| waiting | DRAFT |
|
||||
| order_complete | CONFIRMED |
|
||||
| delivery_scheduled | IN_PROGRESS |
|
||||
| delivery_complete | COMPLETED |
|
||||
|
||||
#### 3. API 함수 구현 (10개)
|
||||
- `getOrderList()` - GET /api/v1/orders
|
||||
- `getOrderStats()` - GET /api/v1/orders/stats
|
||||
- `getOrderDetail()` - GET /api/v1/orders/{id}
|
||||
- `getOrderDetailFull()` - GET /api/v1/orders/{id} (전체 정보)
|
||||
- `createOrder()` - POST /api/v1/orders
|
||||
- `updateOrder()` - PUT /api/v1/orders/{id}
|
||||
- `deleteOrder()` - DELETE /api/v1/orders/{id}
|
||||
- `deleteOrders()` - 개별 삭제 반복 (batch API 미존재)
|
||||
- `duplicateOrder()` - 조회 후 새로 생성
|
||||
- `updateOrderStatus()` - PATCH /api/v1/orders/{id}/status
|
||||
|
||||
### Git 커밋
|
||||
- API: `9f8bff2` feat(common-codes): order_status/order_type 공용 코드 추가
|
||||
- React: `6615f39` feat(order-management): Mock → API 연동 및 common-codes 유틸리티 추가
|
||||
|
||||
### 빌드 검증
|
||||
✅ Next.js 빌드 성공 (349 페이지)
|
||||
|
||||
---
|
||||
|
||||
## 2026-01-09 (목) - TODO-1 결재선/참조 Select 버그 수정
|
||||
|
||||
### 작업 목표
|
||||
- 결재선/참조 Select 컴포넌트에서 선택한 직원 정보가 표시되지 않는 버그 수정
|
||||
- @/lib/api barrel export 추가 (빌드 오류 해결)
|
||||
|
||||
### 수정된 파일
|
||||
| 파일명 | 설명 |
|
||||
|--------|------|
|
||||
| `src/components/approval/DocumentCreate/ApprovalLineSection.tsx` | SelectValue 버그 수정 |
|
||||
| `src/components/approval/DocumentCreate/ReferenceSection.tsx` | SelectValue 버그 수정 |
|
||||
| `src/lib/api/index.ts` | 신규 생성 - barrel export |
|
||||
|
||||
### 주요 변경 내용
|
||||
|
||||
#### 1. SelectValue 버그 수정
|
||||
**문제**: Radix UI SelectValue의 children prop에 조건부 렌더링 사용 시 Select 상태 관리가 깨짐
|
||||
|
||||
**해결**: children 제거, placeholder prop으로 이동
|
||||
```tsx
|
||||
// Before (버그)
|
||||
<SelectValue placeholder="부서명 / 직책명 / 이름 ▼">
|
||||
{person.name ? `${person.department} / ${person.position} / ${person.name}` : null}
|
||||
</SelectValue>
|
||||
|
||||
// After (수정)
|
||||
<SelectValue
|
||||
placeholder={
|
||||
person.name && !person.id.startsWith('temp-')
|
||||
? `${person.department || ''} / ${person.position || ''} / ${person.name}`
|
||||
: "부서명 / 직책명 / 이름 ▼"
|
||||
}
|
||||
/>
|
||||
```
|
||||
|
||||
#### 2. @/lib/api barrel export
|
||||
Phase 2.3 자재관리 작업에서 사용하는 import 경로 지원:
|
||||
```typescript
|
||||
// src/lib/api/index.ts
|
||||
export { ApiClient, withTokenRefresh } from './client';
|
||||
export { serverFetch } from './fetch-wrapper';
|
||||
export { AUTH_CONFIG } from './auth/auth-config';
|
||||
|
||||
export const apiClient = new ApiClient({
|
||||
mode: 'api-key',
|
||||
apiKey: process.env.API_KEY,
|
||||
});
|
||||
```
|
||||
|
||||
### 빌드 검증
|
||||
✅ Next.js 빌드 성공 (349 페이지)
|
||||
|
||||
---
|
||||
|
||||
## 2026-01-09 (목) - Phase 2.3 자재관리(품목관리) API 연동
|
||||
|
||||
### 작업 목표
|
||||
- 시공사 페이지 API 연동 계획 Phase 2.3: 자재관리
|
||||
- `item-management/actions.ts` Mock 데이터 → 실제 API 연동
|
||||
|
||||
### 수정된 파일
|
||||
| 파일명 | 설명 |
|
||||
|--------|------|
|
||||
| `src/components/business/construction/item-management/actions.ts` | Mock → API 완전 재작성 |
|
||||
| `claudedocs/[IMPL-2026-01-09] item-management-api-integration.md` | 구현 문서 |
|
||||
|
||||
### 주요 변경 내용
|
||||
|
||||
#### 1. 타입 변환 함수 추가
|
||||
- `transformItemType()` - Backend item_type → Frontend itemType
|
||||
- `transformToBackendItemType()` - Frontend itemType → Backend item_type
|
||||
- `transformSpecification()` - Backend options → Frontend specification
|
||||
- `transformOrderType()` - Backend options → Frontend orderType
|
||||
- `transformStatus()` - Backend is_active + options → Frontend status
|
||||
- `transformOrderItems()` - Backend options → Frontend orderItems
|
||||
- `transformItem()` - API 응답 → Item 타입
|
||||
- `transformItemDetail()` - API 응답 → ItemDetail 타입
|
||||
- `transformItemToApi()` - ItemFormData → API 요청 데이터
|
||||
|
||||
#### 2. 품목유형 매핑
|
||||
| Frontend | Backend |
|
||||
|----------|---------|
|
||||
| 제품 | FG |
|
||||
| 부품 | PT |
|
||||
| 소모품 | CS |
|
||||
| 공과 | RM |
|
||||
|
||||
#### 3. API 함수 구현 (8개)
|
||||
- `getItemList()` - GET /api/v1/items
|
||||
- `getItemStats()` - GET /api/v1/items/stats
|
||||
- `getItem()` - GET /api/v1/items/{id}
|
||||
- `createItem()` - POST /api/v1/items
|
||||
- `updateItem()` - PUT /api/v1/items/{id}
|
||||
- `deleteItem()` - DELETE /api/v1/items/{id}
|
||||
- `deleteItems()` - DELETE /api/v1/items/batch
|
||||
- `getCategoryOptions()` - GET /api/v1/categories
|
||||
|
||||
#### 4. Frontend 전용 필터링
|
||||
Backend에서 미지원 필터는 Frontend에서 처리:
|
||||
- 규격 (specification)
|
||||
- 구분 (orderType)
|
||||
- 날짜 범위 (startDate, endDate)
|
||||
- 정렬 (sortBy)
|
||||
|
||||
### 관련 API 변경 (api 저장소)
|
||||
- `routes/api.php` - `/items/stats` 라우트 추가
|
||||
|
||||
### 관련 문서
|
||||
- 구현 문서: `claudedocs/[IMPL-2026-01-09] item-management-api-integration.md`
|
||||
|
||||
---
|
||||
|
||||
## 2025-01-09 (목) - 작업지시 process_type → process_id FK 변환
|
||||
|
||||
### 작업 목표
|
||||
- 작업지시의 `process_type` (varchar enum: 'screen'/'slat'/'bending')를 `process_id` (FK → processes.id)로 변환
|
||||
- API와 Frontend 전체 스택 마이그레이션
|
||||
|
||||
### 수정된 파일
|
||||
| 파일명 | 설명 |
|
||||
|--------|------|
|
||||
| `src/components/production/WorkOrders/types.ts` | processId, processName, processCode 필드 추가, transformApiToFrontend에서 processType 하위 호환 유지 |
|
||||
| `src/components/production/WorkOrders/actions.ts` | getProcessOptions() 추가, createWorkOrder에서 processId 사용 |
|
||||
| `src/components/production/WorkOrders/WorkOrderCreate.tsx` | processType enum → processId FK 변경, 동적 공정 옵션 로딩 |
|
||||
| `src/components/production/WorkOrders/WorkOrderList.tsx` | PROCESS_TYPE_LABELS 제거, order.processName 사용 |
|
||||
| `src/components/production/WorkOrders/WorkOrderDetail.tsx` | PROCESS_TYPE_LABELS 제거, order.processName 사용 (비즈니스 로직은 processType 유지) |
|
||||
|
||||
### 주요 변경 내용
|
||||
|
||||
#### 1. types.ts - 타입 및 변환 함수
|
||||
- `WorkOrder` 인터페이스에 `processId`, `processName`, `processCode` 추가
|
||||
- `processType`은 `@deprecated` 마킹, 하위 호환용 유지
|
||||
- `transformApiToFrontend`에서 `processName` → `processType` 자동 매핑
|
||||
|
||||
#### 2. actions.ts - 서버 액션
|
||||
- `getProcessOptions()`: 공정 목록 API 조회 (GET /api/v1/processes)
|
||||
- `createWorkOrder()`: `processId` 필드 사용 (기존 processType 제거)
|
||||
|
||||
#### 3. WorkOrderCreate.tsx - 등록 폼
|
||||
- `processType: ProcessType` → `processId: number | null`
|
||||
- `useEffect`로 공정 옵션 동적 로딩
|
||||
- 첫 번째 공정 자동 선택 (기본값)
|
||||
- Select 컴포넌트 동적 옵션 렌더링
|
||||
|
||||
#### 4. WorkOrderList.tsx / WorkOrderDetail.tsx - 목록/상세
|
||||
- `PROCESS_TYPE_LABELS[order.processType]` → `order.processName`
|
||||
- 비즈니스 로직(ProcessSteps, 절곡 확인)은 `processType` 유지
|
||||
|
||||
### 빌드 검증
|
||||
✅ Next.js 빌드 성공 (TypeScript 오류 없음)
|
||||
|
||||
### 관련 API 변경 (api 저장소)
|
||||
- `WorkOrder` 모델: `process_id` FK 추가, `process()` 관계 정의
|
||||
- `WorkOrderService`: `process_id` 사용
|
||||
- `WorkOrderStoreRequest/UpdateRequest`: `process_id` 검증 규칙
|
||||
|
||||
---
|
||||
|
||||
## 2025-01-09 (목) - 작업지시 코드 리뷰 기반 프론트엔드 개선
|
||||
|
||||
### 작업 목표
|
||||
- 작업지시 기능 코드 리뷰 결과 기반 프론트엔드 개선
|
||||
- Critical, High, Medium 우선순위 항목 전체 수정
|
||||
|
||||
### 수정된 파일
|
||||
| 파일명 | 설명 |
|
||||
|--------|------|
|
||||
| `src/components/production/WorkOrders/WorkOrderList.tsx` | useCallback 의존성 순환 수정 |
|
||||
| `src/components/production/WorkOrders/WorkOrderDetail.tsx` | 작업 버튼 핸들러 구현 |
|
||||
| `src/components/production/WorkOrders/types.ts` | scheduledDate 매핑, 다중 담당자 타입 추가 |
|
||||
| `src/components/production/WorkOrders/actions.ts` | API 경로 수정 (/sales-orders → /orders) |
|
||||
| `src/components/production/WorkOrders/SalesOrderSelectModal.tsx` | debounce 적용 |
|
||||
| `src/components/production/WorkOrders/hooks/useDebounce.ts` | 신규 생성 - 커스텀 debounce 훅 |
|
||||
|
||||
### 주요 변경 내용
|
||||
1. **useCallback 의존성 수정**: 무한 루프 방지를 위한 의존성 배열 수정
|
||||
2. **scheduledDate 매핑**: transformFrontendToApi에 scheduled_date 필드 추가
|
||||
3. **작업 버튼 구현**: "시작"/"완료" 버튼 핸들러 추가
|
||||
4. **API 경로 수정**: `/api/v1/sales-orders` → `/api/v1/orders` 변경
|
||||
5. **debounce 적용**: 커스텀 useDebounce 훅 (300ms) 적용
|
||||
6. **다중 담당자 타입**: WorkOrderAssigneeApi 인터페이스 및 assignees 필드 추가
|
||||
|
||||
### Git 커밋
|
||||
- `12b4259 refactor(work-orders): 코드 리뷰 기반 프론트엔드 개선`
|
||||
|
||||
### 관련 문서
|
||||
- 계획: `~/.claude/plans/purring-sparking-pinwheel.md`
|
||||
|
||||
---
|
||||
|
||||
## 2025-01-02 (목) - 견적 등록 자동산출 기능 구현
|
||||
|
||||
### 작업 목표
|
||||
- 견적 등록 화면에서 BOM 기반 자동산출 기능 구현
|
||||
@@ -43,7 +427,7 @@
|
||||
|
||||
---
|
||||
|
||||
## 2026-01-02 (목) - 채권현황 동적월 지원 및 버그 수정
|
||||
## 2025-01-02 (목) - 채권현황 동적월 지원 및 버그 수정
|
||||
|
||||
### 작업 목표
|
||||
- "최근 1년" 필터 선택 시 동적 월 기간(최근 12개월) 지원
|
||||
@@ -244,14 +628,16 @@ React 컴포넌트에서 Mock 데이터를 실제 API 호출로 교체하는 작
|
||||
- [x] A-5 알림 설정 API 연동
|
||||
- [x] A-6 거래처 원장 (API 미존재로 스킵)
|
||||
|
||||
#### Phase B (진행 중)
|
||||
#### Phase B (✅ 완료)
|
||||
- [x] B-1 매출관리 (SalesManagement) API 연동 ✅
|
||||
- [x] B-2 매입관리 (PurchaseManagement) API 연동 ✅
|
||||
- [x] B-2.1 매입 세금계산서 토글 기능 수정 ✅
|
||||
- [ ] B-3 세금계산서 API 연동
|
||||
- [ ] B-4 입금관리 API 연동
|
||||
- [ ] B-5 출금관리 API 연동
|
||||
- [ ] B-6 미수금현황 API 연동
|
||||
- [x] B-3 입금관리 (DepositManagement) API 연동 ✅
|
||||
- [x] B-4 출금관리 (WithdrawalManagement) API 연동 ✅
|
||||
- [x] B-5 거래처관리 (VendorManagement) API 연동 ✅
|
||||
- [x] B-6 어음관리 (BillManagement) API 연동 ✅
|
||||
|
||||
> **참고**: 원본 계획 문서(`docs/plans/react-mock-to-api-migration-plan.md`)의 Phase B 정의와 일치하도록 수정함
|
||||
|
||||
---
|
||||
|
||||
@@ -388,8 +774,72 @@ useEffect(() => {
|
||||
|
||||
---
|
||||
|
||||
#### Phase C (✅ 완료)
|
||||
- [x] C-1 직원관리 (EmployeeManagement) API 연동 ✅
|
||||
- [x] C-2 근태관리 (AttendanceManagement) API 연동 ✅
|
||||
- [x] C-3 휴가관리 (VacationManagement) API 연동 ✅
|
||||
|
||||
#### Phase D (✅ 완료) - 설정/시스템
|
||||
- [x] D-1 부서관리 (DepartmentManagement) API 연동 ✅
|
||||
- [x] D-2 직급관리 (RankManagement) API 연동 ✅
|
||||
- [x] D-3 직책관리 (TitleManagement) API 연동 ✅
|
||||
- [x] D-4 근무시간설정 (WorkScheduleManagement) API 연동 ✅
|
||||
|
||||
#### Phase E (✅ 완료) - 인사/급여
|
||||
- [x] E-1 급여관리 (SalaryManagement) API 연동 ✅
|
||||
- [x] E-2 카드관리 (CardManagement) API 연동 ✅
|
||||
|
||||
#### Phase F (✅ 완료) - 결재시스템
|
||||
- [x] F-1 기안함 (DraftBox) API 연동 ✅
|
||||
- [x] F-2 결재함 (ApprovalBox) API 연동 ✅
|
||||
- [x] F-3 참조함 (ReferenceBox) API 연동 ✅
|
||||
- [x] F-4 문서작성 (DocumentCreate) API 연동 ✅
|
||||
|
||||
#### Phase G (✅ 완료) - 생산관리
|
||||
- [x] G-1 작업지시 (WorkOrders) API 연동 ✅
|
||||
- [x] G-2 작업실적 (WorkResults) API 연동 ✅
|
||||
- [x] G-3 작업자화면 (WorkerScreen) API 연동 ✅
|
||||
- [x] G-4 생산현황 (ProductionDashboard) API 연동 ✅
|
||||
|
||||
#### Phase H (✅ 완료) - 자재/출하
|
||||
- [x] H-1 재고현황 (StockStatus) API 연동 ✅
|
||||
- [x] H-2 입고관리 (ReceivingManagement) API 연동 ✅
|
||||
- [x] H-3 출하관리 (ShipmentManagement) API 연동 ✅
|
||||
|
||||
#### Phase I (✅ 완료) - 판매/견적
|
||||
- [x] I-1 수주관리 (Orders) API 연동 ✅
|
||||
- [x] I-2 단가관리 (Pricing) API 연동 ✅
|
||||
- [x] I-3 견적관리 (Quotes) API 연동 ✅
|
||||
|
||||
#### Phase J (✅ 완료) - 회계관리
|
||||
- [x] 악성채권, 계좌조회, 어음관리, 카드거래 등 13개 모듈 API 연동 ✅
|
||||
|
||||
#### Phase K (✅ 완료) - 보고서
|
||||
- [x] K-1 종합분석 (Reports) API 연동 ✅
|
||||
|
||||
#### Phase L (🔄 진행중 ~80%) - 건설관리
|
||||
**✅ apiClient 표준화 완료:**
|
||||
- [x] handover-report (b7b8b90)
|
||||
- [x] contract (5db6e59)
|
||||
- [x] partners (5db6e59)
|
||||
- [x] site-management (5db6e59)
|
||||
- [x] order-management (6615f39)
|
||||
- [x] item-management (Phase 2.3)
|
||||
- [x] pricing-management (Phase L) ✅ 2026-01-09
|
||||
- [x] estimates (Phase L) ✅ 2026-01-09
|
||||
- [x] category-management (Phase L) ✅ 2026-01-09
|
||||
|
||||
**⏳ Mock → API 변환 필요 (Backend API 개발 필요):**
|
||||
- [ ] bidding - 입찰관리
|
||||
- [ ] site-briefings - 현장설명회
|
||||
- [ ] structure-review - 구조검토
|
||||
- [ ] labor-management - 노무관리
|
||||
|
||||
> **마이그레이션 진행률**: 97% 완료 (41/43 모듈) - 건설관리 4개 모듈 Backend API 개발 필요
|
||||
> **점검일**: 2026-01-09
|
||||
|
||||
### 다음 작업
|
||||
- B-3 세금계산서 API 연동
|
||||
- B-4 ~ B-6 회계관리 나머지 컴포넌트
|
||||
- Phase L 건설관리 모듈 마이그레이션 완료 (Backend API 개발 필요: bidding, site-briefings, structure-review, labor-management)
|
||||
- ~~TODO-1: 결재선/참조 Select 변경 불가 문제~~ ✅ 2026-01-09 수정 완료
|
||||
|
||||
---
|
||||
|
||||
154
claudedocs/[IMPL-2026-01-09] item-management-api-integration.md
Normal file
154
claudedocs/[IMPL-2026-01-09] item-management-api-integration.md
Normal file
@@ -0,0 +1,154 @@
|
||||
# [IMPL-2026-01-09] 자재관리(품목관리) API 연동
|
||||
|
||||
## 작업 개요
|
||||
- **작업자**: Claude Code
|
||||
- **작업일**: 2026-01-09
|
||||
- **Phase**: 2.3 자재관리 (시공사 페이지 API 연동 계획)
|
||||
- **이전 Phase**: 2.2 거래처관리 완료
|
||||
|
||||
## 변경 사항 요약
|
||||
|
||||
### Backend (api/)
|
||||
|
||||
#### 1. 라우트 추가
|
||||
**파일**: `routes/api.php`
|
||||
|
||||
```php
|
||||
// Items (통합 품목 관리 - items 테이블)
|
||||
Route::prefix('items')->group(function () {
|
||||
Route::get('', [ItemsController::class, 'index'])->name('v1.items.index');
|
||||
Route::get('/stats', [ItemsController::class, 'stats'])->name('v1.items.stats'); // 신규
|
||||
Route::post('', [ItemsController::class, 'store'])->name('v1.items.store');
|
||||
Route::get('/code/{code}', [ItemsController::class, 'showByCode'])->name('v1.items.show_by_code');
|
||||
Route::get('/{id}', [ItemsController::class, 'show'])->name('v1.items.show');
|
||||
Route::put('/{id}', [ItemsController::class, 'update'])->name('v1.items.update');
|
||||
Route::delete('/batch', [ItemsController::class, 'batchDestroy'])->name('v1.items.batch_destroy');
|
||||
Route::delete('/{id}', [ItemsController::class, 'destroy'])->name('v1.items.destroy');
|
||||
});
|
||||
```
|
||||
|
||||
**중요**: `/stats` 라우트는 `/{id}` 보다 먼저 정의하여 "stats"가 ID로 캡처되는 것을 방지
|
||||
|
||||
### Frontend (react/)
|
||||
|
||||
#### 1. actions.ts 완전 재작성
|
||||
**파일**: `src/components/business/construction/item-management/actions.ts`
|
||||
|
||||
**변경 전**: Mock 데이터 기반 (mockItems, mockOrderItems 배열)
|
||||
**변경 후**: 실제 API 연동
|
||||
|
||||
#### 주요 구현 내용
|
||||
|
||||
##### 타입 변환 함수
|
||||
| 함수명 | 용도 |
|
||||
|--------|------|
|
||||
| `transformItemType()` | Backend item_type → Frontend itemType |
|
||||
| `transformToBackendItemType()` | Frontend itemType → Backend item_type |
|
||||
| `transformSpecification()` | Backend options → Frontend specification |
|
||||
| `transformOrderType()` | Backend options → Frontend orderType |
|
||||
| `transformStatus()` | Backend is_active + options → Frontend status |
|
||||
| `transformOrderItems()` | Backend options → Frontend orderItems |
|
||||
| `transformItem()` | API 응답 → Item 타입 |
|
||||
| `transformItemDetail()` | API 응답 → ItemDetail 타입 |
|
||||
| `transformItemToApi()` | ItemFormData → API 요청 데이터 |
|
||||
|
||||
##### 품목 유형 매핑
|
||||
| Frontend (Korean) | Backend (Code) |
|
||||
|-------------------|----------------|
|
||||
| 제품 | FG |
|
||||
| 부품 | PT |
|
||||
| 소모품 | CS (또는 SM) |
|
||||
| 공과 | RM |
|
||||
|
||||
##### API 함수
|
||||
| 함수명 | API Endpoint | 설명 |
|
||||
|--------|-------------|------|
|
||||
| `getItemList()` | GET /api/v1/items | 품목 목록 조회 |
|
||||
| `getItemStats()` | GET /api/v1/items/stats | 품목 통계 조회 |
|
||||
| `getItem()` | GET /api/v1/items/{id} | 품목 상세 조회 |
|
||||
| `createItem()` | POST /api/v1/items | 품목 등록 |
|
||||
| `updateItem()` | PUT /api/v1/items/{id} | 품목 수정 |
|
||||
| `deleteItem()` | DELETE /api/v1/items/{id} | 품목 삭제 |
|
||||
| `deleteItems()` | DELETE /api/v1/items/batch | 품목 일괄 삭제 |
|
||||
| `getCategoryOptions()` | GET /api/v1/categories | 카테고리 목록 조회 |
|
||||
|
||||
##### Frontend 전용 필터링
|
||||
Backend에서 지원하지 않는 필터는 Frontend에서 처리:
|
||||
- 규격 (specification) 필터
|
||||
- 구분 (orderType) 필터
|
||||
- 날짜 범위 (startDate, endDate) 필터
|
||||
- 정렬 (sortBy: latest/oldest)
|
||||
|
||||
## 필드 매핑 상세
|
||||
|
||||
### Item 기본 필드
|
||||
| Frontend | Backend | 변환 방식 |
|
||||
|----------|---------|----------|
|
||||
| id | id | String 변환 |
|
||||
| itemNumber | code | 직접 매핑 |
|
||||
| itemName | name | 직접 매핑 |
|
||||
| itemType | item_type | transformItemType() |
|
||||
| categoryId | category_id | String 변환 |
|
||||
| categoryName | category.name | nested 접근 |
|
||||
| unit | unit | 직접 매핑 (기본값: EA) |
|
||||
| specification | options.specification | transformSpecification() |
|
||||
| orderType | options.orderType | transformOrderType() |
|
||||
| status | is_active + options.status | transformStatus() |
|
||||
| createdAt | created_at | 직접 매핑 |
|
||||
| updatedAt | updated_at | 직접 매핑 |
|
||||
|
||||
### ItemDetail 추가 필드
|
||||
| Frontend | Backend | 변환 방식 |
|
||||
|----------|---------|----------|
|
||||
| note | description | 직접 매핑 |
|
||||
| orderItems | options.orderItems | transformOrderItems() |
|
||||
|
||||
## 테스트 체크리스트
|
||||
|
||||
### API 연동 확인
|
||||
- [ ] 품목 목록 조회 (GET /items)
|
||||
- [ ] 품목 통계 조회 (GET /items/stats)
|
||||
- [ ] 품목 상세 조회 (GET /items/{id})
|
||||
- [ ] 품목 등록 (POST /items)
|
||||
- [ ] 품목 수정 (PUT /items/{id})
|
||||
- [ ] 품목 삭제 (DELETE /items/{id})
|
||||
- [ ] 품목 일괄 삭제 (DELETE /items/batch)
|
||||
- [ ] 카테고리 목록 조회 (GET /categories)
|
||||
|
||||
### 필터링 확인
|
||||
- [ ] 검색 필터 (search → q)
|
||||
- [ ] 품목유형 필터 (itemType → type)
|
||||
- [ ] 카테고리 필터 (categoryId → category_id)
|
||||
- [ ] 활성상태 필터 (status → active)
|
||||
- [ ] 규격 필터 (Frontend only)
|
||||
- [ ] 구분 필터 (Frontend only)
|
||||
- [ ] 날짜 필터 (Frontend only)
|
||||
|
||||
### 데이터 변환 확인
|
||||
- [ ] 품목유형 한글 ↔ 코드 변환
|
||||
- [ ] 상태값 변환 (is_active ↔ status)
|
||||
- [ ] options JSON 필드 파싱/생성
|
||||
|
||||
## 관련 파일
|
||||
|
||||
### 수정된 파일
|
||||
1. `api/routes/api.php` - /items/stats 라우트 추가
|
||||
2. `react/src/components/business/construction/item-management/actions.ts` - Mock → API 변환
|
||||
|
||||
### 참조 파일
|
||||
- `api/app/Http/Controllers/Api/V1/ItemsController.php`
|
||||
- `api/app/Services/ItemService.php`
|
||||
- `react/src/components/business/construction/item-management/types.ts`
|
||||
- `react/src/lib/api.ts`
|
||||
|
||||
## 다음 단계
|
||||
|
||||
### Phase 2.4 예정
|
||||
- 자재관리 (품목관리) UI 컴포넌트 연동 테스트
|
||||
- 에러 핸들링 개선
|
||||
- 로딩 상태 처리
|
||||
|
||||
### 향후 개선 사항
|
||||
- Backend에서 추가 필터 지원 시 Frontend 필터 제거
|
||||
- options 필드 구조 표준화
|
||||
- 품목 일괄 등록 API 추가 고려
|
||||
@@ -0,0 +1,92 @@
|
||||
# 수주 관리 Frontend API 연동
|
||||
|
||||
**날짜:** 2025-01-08
|
||||
**Phase:** Phase 2 - Frontend 연동
|
||||
**관련 Plan:** docs/plans/order-management-plan.md
|
||||
|
||||
## 변경 개요
|
||||
|
||||
수주 관리 React 페이지들을 백엔드 API와 연동 완료. Mock 데이터를 제거하고 실제 API 호출로 대체.
|
||||
|
||||
## 수정된 파일
|
||||
|
||||
### 1. `src/components/orders/actions.ts` (신규 생성)
|
||||
- Server Actions 패턴으로 API 클라이언트 구현
|
||||
- 주요 함수:
|
||||
- `getOrders()`: 수주 목록 조회
|
||||
- `getOrderById(id)`: 수주 상세 조회
|
||||
- `createOrder(data)`: 수주 등록
|
||||
- `updateOrder(id, data)`: 수주 수정
|
||||
- `deleteOrder(id)`: 수주 삭제
|
||||
- `deleteOrders(ids)`: 수주 일괄 삭제
|
||||
- `updateOrderStatus(id, status)`: 수주 상태 변경
|
||||
- `getOrderStats()`: 통계 조회
|
||||
- 데이터 변환: API snake_case → Frontend camelCase
|
||||
- 상태 매핑: API 상태(DRAFT, CONFIRMED 등) → Frontend 상태(order_registered, order_confirmed 등)
|
||||
|
||||
### 2. `src/components/orders/index.ts` (수정)
|
||||
- actions.ts export 추가
|
||||
- 타입 충돌 해결 (OrderItem → OrderItemApi)
|
||||
|
||||
### 3. `src/app/[locale]/(protected)/sales/order-management-sales/page.tsx` (수정)
|
||||
- SAMPLE_ORDERS (~115줄) 제거
|
||||
- API 연동 state 추가: `orders`, `apiStats`, `isLoading`, `isDeleting`
|
||||
- `loadData()` 함수로 API 호출 (getOrders, getOrderStats)
|
||||
- 삭제 핸들러에 API 호출 추가 (deleteOrder, deleteOrders)
|
||||
- 로딩 UI 추가
|
||||
|
||||
### 4. `src/app/[locale]/(protected)/sales/order-management-sales/[id]/page.tsx` (수정)
|
||||
- SAMPLE_ITEMS, SAMPLE_ORDERS (~250줄) 제거
|
||||
- useEffect에서 getOrderById API 호출
|
||||
- handleConfirmCancel에서 updateOrderStatus API 호출
|
||||
- isCancelling 로딩 상태 적용
|
||||
|
||||
### 5. `src/app/[locale]/(protected)/sales/order-management-sales/[id]/edit/page.tsx` (수정)
|
||||
- SAMPLE_ORDER (~50줄) 제거
|
||||
- useEffect에서 getOrderById API 호출
|
||||
- handleSave에서 updateOrder API 호출
|
||||
|
||||
### 6. `src/app/[locale]/(protected)/sales/order-management-sales/new/page.tsx` (수정)
|
||||
- handleSave에서 createOrder API 호출
|
||||
|
||||
## 기술 패턴
|
||||
|
||||
### Server Actions 패턴
|
||||
```typescript
|
||||
"use server";
|
||||
import { serverFetch } from "@/lib/api/serverFetch";
|
||||
|
||||
export async function getOrders() {
|
||||
const response = await serverFetch("/orders");
|
||||
// 데이터 변환 로직
|
||||
}
|
||||
```
|
||||
|
||||
### 데이터 변환
|
||||
- API: `order_no`, `client_name`, `site_name`
|
||||
- Frontend: `orderNo`, `clientName`, `siteName`
|
||||
|
||||
### 상태 매핑
|
||||
| API | Frontend |
|
||||
|-----|----------|
|
||||
| DRAFT | order_registered |
|
||||
| CONFIRMED | order_confirmed |
|
||||
| IN_PROGRESS | production_ordered |
|
||||
| COMPLETED | shipped |
|
||||
| CANCELLED | cancelled |
|
||||
|
||||
## 테스트 체크리스트
|
||||
|
||||
- [ ] 수주 목록 로드
|
||||
- [ ] 수주 상세 조회
|
||||
- [ ] 수주 등록 (견적 선택 후)
|
||||
- [ ] 수주 수정
|
||||
- [ ] 수주 개별 삭제
|
||||
- [ ] 수주 일괄 삭제
|
||||
- [ ] 수주 취소
|
||||
- [ ] 통계 카드 표시
|
||||
|
||||
## 연관 작업
|
||||
|
||||
- Phase 1: Order API 백엔드 구현 (커밋: de19ac9)
|
||||
- Phase 1.1: OrderController/Service 구현 (진행 중)
|
||||
113
claudedocs/changes/20250108_order_phase3_advanced_features.md
Normal file
113
claudedocs/changes/20250108_order_phase3_advanced_features.md
Normal file
@@ -0,0 +1,113 @@
|
||||
# 수주 관리 Phase 3 - 고급 기능
|
||||
|
||||
**날짜:** 2025-01-08
|
||||
**Phase:** Phase 3 - 고급 기능
|
||||
**관련 Plan:** docs/plans/order-management-plan.md
|
||||
|
||||
## 변경 개요
|
||||
|
||||
수주 관리 시스템에 견적→수주 변환 및 생산지시 생성 기능 추가.
|
||||
|
||||
## API 추가 사항
|
||||
|
||||
### 1. 견적에서 수주 생성
|
||||
- **Endpoint**: `POST /api/v1/orders/from-quote/{quoteId}`
|
||||
- **기능**: 기존 견적서를 기반으로 수주를 자동 생성
|
||||
- **검증**: 이미 수주가 생성된 견적은 중복 생성 방지
|
||||
|
||||
### 2. 생산지시 생성
|
||||
- **Endpoint**: `POST /api/v1/orders/{id}/production-order`
|
||||
- **기능**: 확정된 수주에서 작업지시(WorkOrder) 생성
|
||||
- **검증**: CONFIRMED 상태의 수주만 생산지시 가능
|
||||
|
||||
## 수정된 파일
|
||||
|
||||
### API (Laravel)
|
||||
|
||||
#### 1. `app/Services/OrderService.php`
|
||||
- `createFromQuote(int $quoteId, array $data)`: 견적→수주 변환 로직
|
||||
- `createProductionOrder(int $orderId, array $data)`: 생산지시 생성 로직
|
||||
- `generateWorkOrderNo(int $tenantId)`: 작업지시번호 자동 생성
|
||||
|
||||
#### 2. `app/Http/Controllers/Api/V1/OrderController.php`
|
||||
- `createFromQuote()`: 견적→수주 액션
|
||||
- `createProductionOrder()`: 생산지시 생성 액션
|
||||
|
||||
#### 3. `app/Http/Requests/Order/CreateFromQuoteRequest.php` (신규)
|
||||
- 견적→수주 변환 요청 검증
|
||||
- 선택 필드: delivery_date, memo
|
||||
|
||||
#### 4. `app/Http/Requests/Order/CreateProductionOrderRequest.php` (신규)
|
||||
- 생산지시 생성 요청 검증
|
||||
- 선택 필드: process_type, assignee_id, team_id, scheduled_date, memo
|
||||
|
||||
#### 5. `routes/api.php`
|
||||
- `POST /orders/from-quote/{quoteId}`: 견적→수주 라우트
|
||||
- `POST /orders/{id}/production-order`: 생산지시 라우트
|
||||
|
||||
#### 6. `lang/ko/message.php`
|
||||
- `order.created_from_quote`: 견적에서 수주가 생성되었습니다.
|
||||
- `order.production_order_created`: 생산지시가 생성되었습니다.
|
||||
|
||||
#### 7. `lang/ko/error.php`
|
||||
- `order.already_created_from_quote`: 이미 해당 견적에서 수주가 생성되었습니다.
|
||||
- `order.must_be_confirmed_for_production`: 확정 상태의 수주만 생산지시를 생성할 수 있습니다.
|
||||
- `order.production_order_already_exists`: 이미 생산지시가 존재합니다.
|
||||
- `quote.not_found`: 견적을 찾을 수 없습니다.
|
||||
|
||||
### Frontend (React)
|
||||
|
||||
#### 1. `src/components/orders/actions.ts`
|
||||
- 타입 추가: `CreateFromQuoteData`, `CreateProductionOrderData`, `WorkOrder`, `ProductionOrderResult`
|
||||
- API 인터페이스 추가: `ApiWorkOrder`, `ApiProductionOrderResponse`
|
||||
- `createOrderFromQuote(quoteId, data)`: 견적→수주 API 호출
|
||||
- `createProductionOrder(orderId, data)`: 생산지시 생성 API 호출
|
||||
- `transformWorkOrderApiToFrontend()`: WorkOrder 데이터 변환
|
||||
|
||||
## 비즈니스 로직
|
||||
|
||||
### 견적→수주 변환 흐름
|
||||
```
|
||||
Quote (견적)
|
||||
↓ createFromQuote()
|
||||
Order (수주) - DRAFT 상태
|
||||
- quote_id 연결
|
||||
- client, site_name 복사
|
||||
- items 변환 (quantity=calculated_quantity)
|
||||
- 금액 재계산
|
||||
```
|
||||
|
||||
### 생산지시 생성 흐름
|
||||
```
|
||||
Order (수주) - CONFIRMED 상태
|
||||
↓ createProductionOrder()
|
||||
WorkOrder (작업지시) - PENDING 상태
|
||||
- sales_order_id 연결
|
||||
- project_name = site_name
|
||||
- process_type 설정
|
||||
↓
|
||||
Order 상태 → IN_PROGRESS
|
||||
```
|
||||
|
||||
### 상태 전환 규칙 (기존)
|
||||
```
|
||||
DRAFT → CONFIRMED → IN_PROGRESS → COMPLETED
|
||||
↓ ↓ ↓
|
||||
CANCELLED (어느 단계에서든 취소 가능)
|
||||
```
|
||||
|
||||
## 테스트 체크리스트
|
||||
|
||||
- [ ] 견적→수주 생성 (정상 케이스)
|
||||
- [ ] 견적→수주 생성 (중복 방지)
|
||||
- [ ] 견적→수주 생성 (존재하지 않는 견적)
|
||||
- [ ] 생산지시 생성 (정상 케이스)
|
||||
- [ ] 생산지시 생성 (CONFIRMED 아닌 수주)
|
||||
- [ ] 생산지시 생성 (중복 방지)
|
||||
- [ ] 수주 상태 자동 변경 (CONFIRMED → IN_PROGRESS)
|
||||
|
||||
## 연관 작업
|
||||
|
||||
- Phase 1: Order API 백엔드 구현 (커밋: de19ac9)
|
||||
- Phase 2: Frontend API 연동 (커밋: 572ffe8)
|
||||
- Phase 3: 고급 기능 (현재)
|
||||
@@ -0,0 +1,117 @@
|
||||
# Phase 2.2 거래처관리 API 연동
|
||||
|
||||
**날짜**: 2026-01-09
|
||||
**작업**: 거래처관리 Mock → API 연동
|
||||
|
||||
## 개요
|
||||
|
||||
시공사 페이지 API 연동 계획 Phase 2.2 - 거래처관리(partners) API 연동 완료.
|
||||
|
||||
## 변경 사항
|
||||
|
||||
### Backend (API)
|
||||
|
||||
#### 1. 서비스 (ClientService.php)
|
||||
- `stats()` - 거래처 통계 조회 (신규)
|
||||
- total: 전체 거래처 수
|
||||
- sales: 판매 거래처 (client_type='SALES')
|
||||
- purchase: 구매 거래처 (client_type='PURCHASE')
|
||||
- both: 판매/구매 거래처 (client_type='BOTH')
|
||||
- badDebt: 악성채권 보유 거래처 수
|
||||
- normal: 정상 거래처 수
|
||||
- `bulkDestroy()` - 일괄 삭제 (신규)
|
||||
- 주문 존재 시 해당 거래처는 건너뜀
|
||||
|
||||
#### 2. 컨트롤러 (ClientController.php)
|
||||
- `stats()` - GET /api/v1/clients/stats
|
||||
- `bulkDestroy()` - DELETE /api/v1/clients/bulk
|
||||
|
||||
#### 3. 라우트 (api.php)
|
||||
```php
|
||||
Route::get('/stats', [ClientController::class, 'stats']);
|
||||
Route::delete('/bulk', [ClientController::class, 'bulkDestroy']);
|
||||
```
|
||||
|
||||
### Frontend (React)
|
||||
|
||||
#### 1. actions.ts
|
||||
- Mock 데이터 제거 (mockPartners 배열)
|
||||
- API 연동 구현
|
||||
- `getPartnerList()` - GET /api/v1/clients
|
||||
- `getPartner()` - GET /api/v1/clients/{id}
|
||||
- `createPartner()` - POST /api/v1/clients
|
||||
- `updatePartner()` - PUT /api/v1/clients/{id}
|
||||
- `getPartnerStats()` - GET /api/v1/clients/stats
|
||||
- `deletePartner()` - DELETE /api/v1/clients/{id}
|
||||
- `deletePartners()` - DELETE /api/v1/clients/bulk
|
||||
|
||||
#### 2. 변환 함수
|
||||
- `transformClientType()` - client_type → partnerType 변환
|
||||
- `transformPartnerType()` - partnerType → client_type 변환
|
||||
- `transformPartner()` - API 응답 → Partner 타입 변환
|
||||
- `transformPartnerToApi()` - PartnerFormData → API 요청 데이터 변환
|
||||
|
||||
## API 매핑
|
||||
|
||||
| Frontend | Backend | 비고 |
|
||||
|----------|---------|------|
|
||||
| id | id | string ↔ int |
|
||||
| partnerCode | client_code | 자동 생성 |
|
||||
| businessNumber | business_no | |
|
||||
| partnerName | name | |
|
||||
| representative | contact_person | |
|
||||
| partnerType | client_type | sales/SALES, purchase/PURCHASE, both/BOTH |
|
||||
| businessType | business_type | |
|
||||
| businessCategory | business_item | |
|
||||
| address1 | address | |
|
||||
| phone | phone | |
|
||||
| mobile | mobile | |
|
||||
| fax | fax | |
|
||||
| email | email | |
|
||||
| manager | manager_name | |
|
||||
| managerPhone | manager_tel | |
|
||||
| systemManager | system_manager | |
|
||||
| outstandingAmount | outstanding_amount | 계산 필드 (매출-입금) |
|
||||
| overdueToggle | is_overdue | |
|
||||
| isBadDebt | has_bad_debt | 계산 필드 |
|
||||
| isActive | is_active | |
|
||||
| createdAt | created_at | |
|
||||
| updatedAt | updated_at | |
|
||||
|
||||
### Frontend 전용 필드 (기본값 사용)
|
||||
- zipCode, address2: ''
|
||||
- logoUrl, logoBlob: null
|
||||
- salesPaymentDay, paymentDay: 0
|
||||
- creditRating, transactionGrade: ''
|
||||
- memos, documents: []
|
||||
- category: ''
|
||||
- overdueDays: is_overdue ? 30 : 0
|
||||
|
||||
## 설계 결정
|
||||
|
||||
### 기존 Client API 재사용
|
||||
- `/api/v1/clients` 기존 엔드포인트 확장 사용
|
||||
- 별도의 `/api/v1/construction/partners` 생성하지 않음
|
||||
- accounting/vendors 와 construction/partners 모두 Client API 사용
|
||||
|
||||
### 악성채권 통계
|
||||
- BadDebt 테이블과 연계하여 악성채권 보유 거래처 수 계산
|
||||
- 상태가 '추심중' 또는 '법적조치'인 활성 악성채권만 카운트
|
||||
|
||||
### 필터링 전략
|
||||
- 검색(`q`): API에서 처리 (name, client_code, contact_person)
|
||||
- 악성채권 필터: 프론트엔드에서 처리 (API 전체 반환 후 필터)
|
||||
- 정렬: 프론트엔드에서 처리 (API 기본 정렬 사용)
|
||||
|
||||
## 진행률
|
||||
|
||||
시공사 API 연동: 4/9 (44%)
|
||||
- [x] Phase 1.1 견적관리
|
||||
- [x] Phase 1.2 인수인계보고서관리
|
||||
- [x] Phase 2.1 현장관리
|
||||
- [x] Phase 2.2 거래처관리 ← 현재 완료
|
||||
- [ ] Phase 2.3 자재관리
|
||||
- [ ] Phase 3.1 발주관리
|
||||
- [ ] Phase 3.2 재고관리
|
||||
- [ ] Phase 4.1 정산관리
|
||||
- [ ] Phase 4.2 급여관리
|
||||
@@ -0,0 +1,90 @@
|
||||
# Phase 2.1 현장관리 API 연동
|
||||
|
||||
**날짜**: 2026-01-09
|
||||
**작업**: 현장관리 Mock → API 연동
|
||||
|
||||
## 개요
|
||||
|
||||
시공사 페이지 API 연동 계획 Phase 2.1 - 현장관리(site-management) API 연동 완료.
|
||||
|
||||
## 변경 사항
|
||||
|
||||
### Backend (API)
|
||||
|
||||
#### 1. 마이그레이션
|
||||
- `2026_01_09_162534_add_construction_fields_to_sites_table.php`
|
||||
- `site_code` (VARCHAR 50) - 현장코드
|
||||
- `client_id` (FK → clients) - 거래처 연결
|
||||
- `status` (ENUM) - unregistered/suspended/active/pending
|
||||
- 인덱스: tenant_id + site_code, tenant_id + status
|
||||
|
||||
#### 2. 모델 (Site.php)
|
||||
- 상태 상수 추가: STATUS_UNREGISTERED, STATUS_SUSPENDED, STATUS_ACTIVE, STATUS_PENDING
|
||||
- fillable 확장: site_code, client_id, status
|
||||
- Client 관계 추가
|
||||
|
||||
#### 3. 서비스 (SiteService.php)
|
||||
- `index()` - 필터 확장 (status, client_id, start_date, end_date)
|
||||
- `stats()` - 상태별 통계 조회 (신규)
|
||||
- `bulkDestroy()` - 일괄 삭제 (신규)
|
||||
|
||||
#### 4. 컨트롤러 (SiteController.php)
|
||||
- `stats()` - GET /api/v1/sites/stats
|
||||
- `bulkDestroy()` - DELETE /api/v1/sites/bulk
|
||||
|
||||
#### 5. 라우트 (api.php)
|
||||
```php
|
||||
Route::get('/stats', [SiteController::class, 'stats']);
|
||||
Route::delete('/bulk', [SiteController::class, 'bulkDestroy']);
|
||||
```
|
||||
|
||||
### Frontend (React)
|
||||
|
||||
#### 1. types.ts
|
||||
- SiteStats에 suspended, pending 필드 추가
|
||||
|
||||
#### 2. actions.ts
|
||||
- Mock 데이터 제거
|
||||
- API 연동 구현
|
||||
- `getSiteList()` - GET /api/v1/sites
|
||||
- `getSiteStats()` - GET /api/v1/sites/stats
|
||||
- `deleteSite()` - DELETE /api/v1/sites/{id}
|
||||
- `deleteSites()` - DELETE /api/v1/sites/bulk
|
||||
|
||||
## API 매핑
|
||||
|
||||
| Frontend | Backend | 비고 |
|
||||
|----------|---------|------|
|
||||
| id | id | string ↔ int |
|
||||
| siteCode | site_code | |
|
||||
| partnerId | client_id | |
|
||||
| partnerName | client.name | 관계 eager load |
|
||||
| siteName | name | |
|
||||
| address | address | |
|
||||
| status | status | 동일 |
|
||||
| createdAt | created_at | |
|
||||
| updatedAt | updated_at | |
|
||||
|
||||
## 설계 결정
|
||||
|
||||
### is_active vs status
|
||||
- `is_active` (boolean): 사용 여부 (활성화/비활성화)
|
||||
- `status` (enum): 상태값 (미등록/중지/사용/보류)
|
||||
- 두 필드는 다른 용도로 둘 다 유지
|
||||
|
||||
### 기존 API 활용
|
||||
- `/api/v1/sites` 기존 엔드포인트 확장 사용
|
||||
- `/api/v1/construction/sites` 별도 생성하지 않음
|
||||
|
||||
## 진행률
|
||||
|
||||
시공사 API 연동: 3/9 (33%)
|
||||
- [x] Phase 1.1 견적관리
|
||||
- [x] Phase 1.2 인수인계보고서관리
|
||||
- [x] Phase 2.1 현장관리 ← 현재 완료
|
||||
- [ ] Phase 2.2 거래처관리
|
||||
- [ ] Phase 2.3 자재관리
|
||||
- [ ] Phase 3.1 발주관리
|
||||
- [ ] Phase 3.2 재고관리
|
||||
- [ ] Phase 4.1 정산관리
|
||||
- [ ] Phase 4.2 급여관리
|
||||
2991
package-lock.json
generated
2991
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -3,204 +3,36 @@
|
||||
import { use, useEffect, useState } from 'react';
|
||||
import { EstimateDetailForm } from '@/components/business/construction/estimates';
|
||||
import type { EstimateDetail } from '@/components/business/construction/estimates';
|
||||
import { getEstimateDetail } from '@/components/business/construction/estimates/actions';
|
||||
|
||||
interface EstimateEditPageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
// 목업 데이터 - 추후 API 연동
|
||||
function getEstimateDetail(id: string): EstimateDetail {
|
||||
// TODO: 실제 API 연동
|
||||
const mockData: EstimateDetail = {
|
||||
id,
|
||||
estimateCode: '123123',
|
||||
partnerId: '1',
|
||||
partnerName: '회사명',
|
||||
projectName: '현장명',
|
||||
estimatorId: 'hong',
|
||||
estimatorName: '이름',
|
||||
estimateCompanyManager: '홍길동',
|
||||
estimateCompanyManagerContact: '01012341234',
|
||||
itemCount: 21,
|
||||
estimateAmount: 1420000,
|
||||
completedDate: null,
|
||||
bidDate: '2025-12-12',
|
||||
status: 'pending',
|
||||
createdAt: '2025-12-01',
|
||||
updatedAt: '2025-12-01',
|
||||
createdBy: 'hong',
|
||||
siteBriefing: {
|
||||
briefingCode: '123123',
|
||||
partnerName: '회사명',
|
||||
companyName: '회사명',
|
||||
briefingDate: '2025-12-12',
|
||||
attendee: '이름',
|
||||
},
|
||||
bidInfo: {
|
||||
projectName: '현장명',
|
||||
bidDate: '2025-12-12',
|
||||
siteCount: 21,
|
||||
constructionPeriod: '2026-01-01 ~ 2026-12-10',
|
||||
constructionStartDate: '2026-01-01',
|
||||
constructionEndDate: '2026-12-10',
|
||||
vatType: 'excluded',
|
||||
workReport: '업무 보고 내용',
|
||||
documents: [
|
||||
{
|
||||
id: '1',
|
||||
fileName: 'abc.zip',
|
||||
fileUrl: '#',
|
||||
fileSize: 1024000,
|
||||
},
|
||||
],
|
||||
},
|
||||
summaryItems: [
|
||||
{
|
||||
id: '1',
|
||||
name: '서터 심창측공사',
|
||||
quantity: 1,
|
||||
unit: '식',
|
||||
materialCost: 78540000,
|
||||
laborCost: 15410000,
|
||||
totalCost: 93950000,
|
||||
remarks: '',
|
||||
},
|
||||
],
|
||||
expenseItems: [
|
||||
{
|
||||
id: '1',
|
||||
name: 'public_1',
|
||||
amount: 10000,
|
||||
},
|
||||
],
|
||||
priceAdjustments: [
|
||||
{
|
||||
id: '1',
|
||||
category: '배합비',
|
||||
unitPrice: 10000,
|
||||
coating: 10000,
|
||||
batting: 10000,
|
||||
boxReinforce: 10500,
|
||||
painting: 10500,
|
||||
total: 51000,
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
category: '재단비',
|
||||
unitPrice: 1375,
|
||||
coating: 0,
|
||||
batting: 0,
|
||||
boxReinforce: 0,
|
||||
painting: 0,
|
||||
total: 1375,
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
category: '판매단가',
|
||||
unitPrice: 0,
|
||||
coating: 10000,
|
||||
batting: 10000,
|
||||
boxReinforce: 10500,
|
||||
painting: 10500,
|
||||
total: 41000,
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
category: '조립단가',
|
||||
unitPrice: 10300,
|
||||
coating: 10300,
|
||||
batting: 10300,
|
||||
boxReinforce: 10500,
|
||||
painting: 10200,
|
||||
total: 51600,
|
||||
},
|
||||
],
|
||||
detailItems: [
|
||||
{
|
||||
id: '1',
|
||||
no: 1,
|
||||
name: 'FS530외/주차',
|
||||
material: 'screen',
|
||||
width: 2350,
|
||||
height: 2500,
|
||||
quantity: 1,
|
||||
box: 1,
|
||||
assembly: 0,
|
||||
coating: 0,
|
||||
batting: 0,
|
||||
mounting: 0,
|
||||
fitting: 0,
|
||||
controller: 0,
|
||||
widthConstruction: 0,
|
||||
heightConstruction: 0,
|
||||
materialCost: 1420000,
|
||||
laborCost: 510000,
|
||||
quantityPrice: 1930000,
|
||||
expenseQuantity: 5500,
|
||||
expenseTotal: 5500,
|
||||
totalCost: 1930000,
|
||||
otherCost: 0,
|
||||
marginCost: 0,
|
||||
totalPrice: 1930000,
|
||||
unitPrice: 1420000,
|
||||
expense: 0,
|
||||
marginRate: 0,
|
||||
unitQuantity: 1,
|
||||
expenseResult: 0,
|
||||
marginActual: 0,
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
no: 2,
|
||||
name: 'FS530외/주차',
|
||||
material: 'screen',
|
||||
width: 7500,
|
||||
height: 2500,
|
||||
quantity: 1,
|
||||
box: 1,
|
||||
assembly: 0,
|
||||
coating: 0,
|
||||
batting: 0,
|
||||
mounting: 0,
|
||||
fitting: 0,
|
||||
controller: 0,
|
||||
widthConstruction: 0,
|
||||
heightConstruction: 0,
|
||||
materialCost: 4720000,
|
||||
laborCost: 780000,
|
||||
quantityPrice: 5500000,
|
||||
expenseQuantity: 5500,
|
||||
expenseTotal: 5500,
|
||||
totalCost: 5500000,
|
||||
otherCost: 0,
|
||||
marginCost: 0,
|
||||
totalPrice: 5500000,
|
||||
unitPrice: 4720000,
|
||||
expense: 0,
|
||||
marginRate: 0,
|
||||
unitQuantity: 1,
|
||||
expenseResult: 0,
|
||||
marginActual: 0,
|
||||
},
|
||||
],
|
||||
approval: {
|
||||
approvers: [],
|
||||
references: [],
|
||||
},
|
||||
};
|
||||
|
||||
return mockData;
|
||||
}
|
||||
|
||||
export default function EstimateEditPage({ params }: EstimateEditPageProps) {
|
||||
const { id } = use(params);
|
||||
const [data, setData] = useState<EstimateDetail | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const detail = getEstimateDetail(id);
|
||||
setData(detail);
|
||||
setIsLoading(false);
|
||||
async function fetchData() {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
const result = await getEstimateDetail(id);
|
||||
if (result.success && result.data) {
|
||||
setData(result.data);
|
||||
} else {
|
||||
setError(result.error || '견적 정보를 불러오는데 실패했습니다.');
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '오류가 발생했습니다.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
fetchData();
|
||||
}, [id]);
|
||||
|
||||
if (isLoading) {
|
||||
@@ -211,6 +43,14 @@ export default function EstimateEditPage({ params }: EstimateEditPageProps) {
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-red-500">{error}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<EstimateDetailForm
|
||||
mode="edit"
|
||||
|
||||
@@ -3,204 +3,36 @@
|
||||
import { use, useEffect, useState } from 'react';
|
||||
import { EstimateDetailForm } from '@/components/business/construction/estimates';
|
||||
import type { EstimateDetail } from '@/components/business/construction/estimates';
|
||||
import { getEstimateDetail } from '@/components/business/construction/estimates/actions';
|
||||
|
||||
interface EstimateDetailPageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
// 목업 데이터 - 추후 API 연동
|
||||
function getEstimateDetail(id: string): EstimateDetail {
|
||||
// TODO: 실제 API 연동
|
||||
const mockData: EstimateDetail = {
|
||||
id,
|
||||
estimateCode: '123123',
|
||||
partnerId: '1',
|
||||
partnerName: '회사명',
|
||||
projectName: '현장명',
|
||||
estimatorId: 'hong',
|
||||
estimatorName: '이름',
|
||||
estimateCompanyManager: '홍길동',
|
||||
estimateCompanyManagerContact: '01012341234',
|
||||
itemCount: 21,
|
||||
estimateAmount: 1420000,
|
||||
completedDate: null,
|
||||
bidDate: '2025-12-12',
|
||||
status: 'pending',
|
||||
createdAt: '2025-12-01',
|
||||
updatedAt: '2025-12-01',
|
||||
createdBy: 'hong',
|
||||
siteBriefing: {
|
||||
briefingCode: '123123',
|
||||
partnerName: '회사명',
|
||||
companyName: '회사명',
|
||||
briefingDate: '2025-12-12',
|
||||
attendee: '이름',
|
||||
},
|
||||
bidInfo: {
|
||||
projectName: '현장명',
|
||||
bidDate: '2025-12-12',
|
||||
siteCount: 21,
|
||||
constructionPeriod: '2026-01-01 ~ 2026-12-10',
|
||||
constructionStartDate: '2026-01-01',
|
||||
constructionEndDate: '2026-12-10',
|
||||
vatType: 'excluded',
|
||||
workReport: '업무 보고 내용',
|
||||
documents: [
|
||||
{
|
||||
id: '1',
|
||||
fileName: 'abc.zip',
|
||||
fileUrl: '#',
|
||||
fileSize: 1024000,
|
||||
},
|
||||
],
|
||||
},
|
||||
summaryItems: [
|
||||
{
|
||||
id: '1',
|
||||
name: '서터 심창측공사',
|
||||
quantity: 1,
|
||||
unit: '식',
|
||||
materialCost: 78540000,
|
||||
laborCost: 15410000,
|
||||
totalCost: 93950000,
|
||||
remarks: '',
|
||||
},
|
||||
],
|
||||
expenseItems: [
|
||||
{
|
||||
id: '1',
|
||||
name: 'public_1',
|
||||
amount: 10000,
|
||||
},
|
||||
],
|
||||
priceAdjustments: [
|
||||
{
|
||||
id: '1',
|
||||
category: '배합비',
|
||||
unitPrice: 10000,
|
||||
coating: 10000,
|
||||
batting: 10000,
|
||||
boxReinforce: 10500,
|
||||
painting: 10500,
|
||||
total: 51000,
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
category: '재단비',
|
||||
unitPrice: 1375,
|
||||
coating: 0,
|
||||
batting: 0,
|
||||
boxReinforce: 0,
|
||||
painting: 0,
|
||||
total: 1375,
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
category: '판매단가',
|
||||
unitPrice: 0,
|
||||
coating: 10000,
|
||||
batting: 10000,
|
||||
boxReinforce: 10500,
|
||||
painting: 10500,
|
||||
total: 41000,
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
category: '조립단가',
|
||||
unitPrice: 10300,
|
||||
coating: 10300,
|
||||
batting: 10300,
|
||||
boxReinforce: 10500,
|
||||
painting: 10200,
|
||||
total: 51600,
|
||||
},
|
||||
],
|
||||
detailItems: [
|
||||
{
|
||||
id: '1',
|
||||
no: 1,
|
||||
name: 'FS530외/주차',
|
||||
material: 'screen',
|
||||
width: 2350,
|
||||
height: 2500,
|
||||
quantity: 1,
|
||||
box: 1,
|
||||
assembly: 0,
|
||||
coating: 0,
|
||||
batting: 0,
|
||||
mounting: 0,
|
||||
fitting: 0,
|
||||
controller: 0,
|
||||
widthConstruction: 0,
|
||||
heightConstruction: 0,
|
||||
materialCost: 1420000,
|
||||
laborCost: 510000,
|
||||
quantityPrice: 1930000,
|
||||
expenseQuantity: 5500,
|
||||
expenseTotal: 5500,
|
||||
totalCost: 1930000,
|
||||
otherCost: 0,
|
||||
marginCost: 0,
|
||||
totalPrice: 1930000,
|
||||
unitPrice: 1420000,
|
||||
expense: 0,
|
||||
marginRate: 0,
|
||||
unitQuantity: 1,
|
||||
expenseResult: 0,
|
||||
marginActual: 0,
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
no: 2,
|
||||
name: 'FS530외/주차',
|
||||
material: 'screen',
|
||||
width: 7500,
|
||||
height: 2500,
|
||||
quantity: 1,
|
||||
box: 1,
|
||||
assembly: 0,
|
||||
coating: 0,
|
||||
batting: 0,
|
||||
mounting: 0,
|
||||
fitting: 0,
|
||||
controller: 0,
|
||||
widthConstruction: 0,
|
||||
heightConstruction: 0,
|
||||
materialCost: 4720000,
|
||||
laborCost: 780000,
|
||||
quantityPrice: 5500000,
|
||||
expenseQuantity: 5500,
|
||||
expenseTotal: 5500,
|
||||
totalCost: 5500000,
|
||||
otherCost: 0,
|
||||
marginCost: 0,
|
||||
totalPrice: 5500000,
|
||||
unitPrice: 4720000,
|
||||
expense: 0,
|
||||
marginRate: 0,
|
||||
unitQuantity: 1,
|
||||
expenseResult: 0,
|
||||
marginActual: 0,
|
||||
},
|
||||
],
|
||||
approval: {
|
||||
approvers: [],
|
||||
references: [],
|
||||
},
|
||||
};
|
||||
|
||||
return mockData;
|
||||
}
|
||||
|
||||
export default function EstimateDetailPage({ params }: EstimateDetailPageProps) {
|
||||
const { id } = use(params);
|
||||
const [data, setData] = useState<EstimateDetail | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const detail = getEstimateDetail(id);
|
||||
setData(detail);
|
||||
setIsLoading(false);
|
||||
async function fetchData() {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
const result = await getEstimateDetail(id);
|
||||
if (result.success && result.data) {
|
||||
setData(result.data);
|
||||
} else {
|
||||
setError(result.error || '견적 정보를 불러오는데 실패했습니다.');
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '오류가 발생했습니다.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
fetchData();
|
||||
}, [id]);
|
||||
|
||||
if (isLoading) {
|
||||
@@ -211,6 +43,14 @@ export default function EstimateDetailPage({ params }: EstimateDetailPageProps)
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-red-500">{error}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<EstimateDetailForm
|
||||
mode="view"
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
* - 페이지 기반 CRUD (등록/수정/상세 → 별도 페이지로 이동)
|
||||
*/
|
||||
|
||||
import { useState, useRef, useEffect, useCallback } from "react";
|
||||
import { useState, useRef, useEffect, useCallback, useTransition } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useClientList, Client } from "@/hooks/useClientList";
|
||||
import {
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
XCircle,
|
||||
Eye,
|
||||
Loader2,
|
||||
Bell,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
@@ -53,9 +54,12 @@ import {
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { sendNewClientNotification } from "@/lib/actions/fcm";
|
||||
import { isNextRedirectError } from "@/lib/utils/redirect-error";
|
||||
|
||||
export default function CustomerAccountManagementPage() {
|
||||
const router = useRouter();
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
// API 훅 사용
|
||||
const {
|
||||
@@ -336,6 +340,23 @@ export default function CustomerAccountManagementPage() {
|
||||
}
|
||||
};
|
||||
|
||||
// FCM 알림 발송 핸들러
|
||||
const handleSendNotification = useCallback(async () => {
|
||||
startTransition(async () => {
|
||||
try {
|
||||
const result = await sendNewClientNotification();
|
||||
if (result.success) {
|
||||
toast.success(`신규업체 알림을 발송했습니다. (${result.sentCount || 0}건)`);
|
||||
} else {
|
||||
toast.error(result.error || "알림 발송에 실패했습니다.");
|
||||
}
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
toast.error("알림 발송 중 오류가 발생했습니다.");
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
// 상태 뱃지
|
||||
const getStatusBadge = (status: "활성" | "비활성") => {
|
||||
if (status === "활성") {
|
||||
@@ -578,10 +599,16 @@ export default function CustomerAccountManagementPage() {
|
||||
description="거래처 정보 및 계정을 관리합니다"
|
||||
icon={Building2}
|
||||
headerActions={
|
||||
<Button className="ml-auto" onClick={handleAddNew}>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
거래처 등록
|
||||
</Button>
|
||||
<div className="flex gap-2 ml-auto">
|
||||
<Button variant="outline" onClick={handleSendNotification} disabled={isPending}>
|
||||
<Bell className="w-4 h-4 mr-2" />
|
||||
신규업체
|
||||
</Button>
|
||||
<Button onClick={handleAddNew}>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
거래처 등록
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
stats={stats}
|
||||
searchValue={searchTerm}
|
||||
|
||||
@@ -13,7 +13,6 @@ 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";
|
||||
@@ -32,26 +31,19 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { FileText, ArrowLeft, Info, AlertTriangle } from "lucide-react";
|
||||
import { FileText, 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";
|
||||
import {
|
||||
OrderItem,
|
||||
getOrderById,
|
||||
updateOrder,
|
||||
type OrderStatus,
|
||||
} from "@/components/orders";
|
||||
|
||||
// 수정 폼 데이터
|
||||
interface EditFormData {
|
||||
@@ -99,60 +91,6 @@ const SHIPPING_COSTS = [
|
||||
{ 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) {
|
||||
@@ -183,21 +121,57 @@ export default function OrderEditPage() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
// 데이터 로드
|
||||
// 데이터 로드 (API)
|
||||
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}`);
|
||||
};
|
||||
async function loadOrder() {
|
||||
try {
|
||||
setLoading(true);
|
||||
const result = await getOrderById(orderId);
|
||||
if (result.success && result.data) {
|
||||
const order = result.data;
|
||||
// 상태에 따라 품목 수정 가능 여부 결정
|
||||
const canEditItems = !["in_production", "rework", "work_completed", "shipped"].includes(
|
||||
order.status
|
||||
);
|
||||
// Order 데이터를 EditFormData로 변환
|
||||
setForm({
|
||||
lotNumber: order.lotNumber,
|
||||
quoteNumber: order.quoteNumber || "",
|
||||
client: order.client,
|
||||
siteName: order.siteName,
|
||||
manager: order.manager || "",
|
||||
contact: order.contact || "",
|
||||
status: order.status,
|
||||
expectedShipDate: order.expectedShipDate || "",
|
||||
expectedShipDateUndecided: !order.expectedShipDate,
|
||||
deliveryRequestDate: order.deliveryRequestDate || "",
|
||||
deliveryMethod: order.deliveryMethod || "",
|
||||
shippingCost: order.shippingCost || "",
|
||||
receiver: order.receiver || "",
|
||||
receiverContact: order.receiverContact || "",
|
||||
address: order.address || "",
|
||||
addressDetail: order.addressDetail || "",
|
||||
remarks: order.remarks || "",
|
||||
items: order.items || [],
|
||||
canEditItems,
|
||||
subtotal: order.subtotal || order.amount,
|
||||
discountRate: order.discountRate || 0,
|
||||
totalAmount: order.amount,
|
||||
});
|
||||
} else {
|
||||
toast.error(result.error || "수주 정보를 불러오는데 실패했습니다.");
|
||||
router.push("/sales/order-management-sales");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error loading order:", error);
|
||||
toast.error("수주 정보를 불러오는 중 오류가 발생했습니다.");
|
||||
router.push("/sales/order-management-sales");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
loadOrder();
|
||||
}, [orderId, router]);
|
||||
|
||||
const handleCancel = () => {
|
||||
router.push(`/sales/order-management-sales/${orderId}`);
|
||||
@@ -222,11 +196,39 @@ export default function OrderEditPage() {
|
||||
|
||||
setIsSaving(true);
|
||||
try {
|
||||
// TODO: API 연동
|
||||
console.log("수주 수정 데이터:", form);
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
toast.success("수주가 수정되었습니다.");
|
||||
router.push(`/sales/order-management-sales/${orderId}`);
|
||||
// API 연동
|
||||
const result = await updateOrder(orderId, {
|
||||
clientId: undefined, // 기존 값 유지
|
||||
siteName: form.siteName,
|
||||
expectedShipDate: form.expectedShipDateUndecided ? undefined : form.expectedShipDate,
|
||||
deliveryRequestDate: form.deliveryRequestDate,
|
||||
deliveryMethod: form.deliveryMethod,
|
||||
shippingCost: form.shippingCost,
|
||||
receiver: form.receiver,
|
||||
receiverContact: form.receiverContact,
|
||||
address: form.address,
|
||||
addressDetail: form.addressDetail,
|
||||
remarks: form.remarks,
|
||||
items: form.items.map((item) => ({
|
||||
itemId: item.id ? parseInt(item.id, 10) : undefined,
|
||||
itemCode: item.itemCode,
|
||||
itemName: item.itemName,
|
||||
specification: item.spec,
|
||||
quantity: item.quantity,
|
||||
unit: item.unit,
|
||||
unitPrice: item.unitPrice,
|
||||
})),
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
toast.success("수주가 수정되었습니다.");
|
||||
router.push(`/sales/order-management-sales/${orderId}`);
|
||||
} else {
|
||||
toast.error(result.error || "수주 수정에 실패했습니다.");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error updating order:", error);
|
||||
toast.error("수주 수정 중 오류가 발생했습니다.");
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
FileCheck,
|
||||
ClipboardList,
|
||||
Eye,
|
||||
CheckCircle2,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { PageLayout } from "@/components/organisms/PageLayout";
|
||||
@@ -54,266 +55,14 @@ import {
|
||||
} from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
OrderItem,
|
||||
OrderDocumentModal,
|
||||
type OrderDocumentType,
|
||||
getOrderById,
|
||||
updateOrderStatus,
|
||||
type Order,
|
||||
type OrderStatus,
|
||||
} 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) {
|
||||
@@ -350,9 +99,12 @@ export default function OrderDetailPage() {
|
||||
const params = useParams();
|
||||
const orderId = params.id as string;
|
||||
|
||||
const [order, setOrder] = useState<OrderDetail | null>(null);
|
||||
const [order, setOrder] = useState<Order | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [isCancelDialogOpen, setIsCancelDialogOpen] = useState(false);
|
||||
const [isCancelling, setIsCancelling] = useState(false);
|
||||
const [isConfirmDialogOpen, setIsConfirmDialogOpen] = useState(false);
|
||||
const [isConfirming, setIsConfirming] = useState(false);
|
||||
|
||||
// 취소 폼 상태
|
||||
const [cancelReason, setCancelReason] = useState("");
|
||||
@@ -362,14 +114,27 @@ export default function OrderDetailPage() {
|
||||
const [documentModalOpen, setDocumentModalOpen] = useState(false);
|
||||
const [documentType, setDocumentType] = useState<OrderDocumentType>("contract");
|
||||
|
||||
// 데이터 로드 (샘플 - ID로 매칭)
|
||||
// 데이터 로드 (API 호출)
|
||||
useEffect(() => {
|
||||
// 실제 구현에서는 API 호출
|
||||
setTimeout(() => {
|
||||
const foundOrder = SAMPLE_ORDERS[orderId];
|
||||
setOrder(foundOrder || null);
|
||||
setLoading(false);
|
||||
}, 300);
|
||||
async function loadOrder() {
|
||||
try {
|
||||
setLoading(true);
|
||||
const result = await getOrderById(orderId);
|
||||
if (result.success && result.data) {
|
||||
setOrder(result.data);
|
||||
} else {
|
||||
toast.error(result.error || "수주 정보를 불러오는데 실패했습니다.");
|
||||
setOrder(null);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error loading order:", error);
|
||||
toast.error("수주 정보를 불러오는 중 오류가 발생했습니다.");
|
||||
setOrder(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
loadOrder();
|
||||
}, [orderId]);
|
||||
|
||||
const handleBack = () => {
|
||||
@@ -396,18 +161,57 @@ export default function OrderDetailPage() {
|
||||
setIsCancelDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleConfirmCancel = () => {
|
||||
const handleConfirmCancel = async () => {
|
||||
if (!cancelReason) {
|
||||
toast.error("취소 사유를 선택해주세요.");
|
||||
return;
|
||||
}
|
||||
if (order) {
|
||||
setOrder({ ...order, status: "cancelled" });
|
||||
toast.success("수주가 취소되었습니다.");
|
||||
setIsCancelling(true);
|
||||
try {
|
||||
const result = await updateOrderStatus(order.id, "cancelled");
|
||||
if (result.success) {
|
||||
setOrder({ ...order, status: "cancelled" });
|
||||
toast.success("수주가 취소되었습니다.");
|
||||
setIsCancelDialogOpen(false);
|
||||
setCancelReason("");
|
||||
setCancelDetail("");
|
||||
} else {
|
||||
toast.error(result.error || "수주 취소에 실패했습니다.");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error cancelling order:", error);
|
||||
toast.error("수주 취소 중 오류가 발생했습니다.");
|
||||
} finally {
|
||||
setIsCancelling(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 수주 확정 처리
|
||||
const handleConfirmOrder = () => {
|
||||
setIsConfirmDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleConfirmOrderSubmit = async () => {
|
||||
if (order) {
|
||||
setIsConfirming(true);
|
||||
try {
|
||||
const result = await updateOrderStatus(order.id, "order_confirmed");
|
||||
if (result.success && result.data) {
|
||||
setOrder(result.data);
|
||||
toast.success("수주가 확정되었습니다.");
|
||||
setIsConfirmDialogOpen(false);
|
||||
} else {
|
||||
toast.error(result.error || "수주 확정에 실패했습니다.");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error confirming order:", error);
|
||||
toast.error("수주 확정 중 오류가 발생했습니다.");
|
||||
} finally {
|
||||
setIsConfirming(false);
|
||||
}
|
||||
}
|
||||
setIsCancelDialogOpen(false);
|
||||
setCancelReason("");
|
||||
setCancelDetail("");
|
||||
};
|
||||
|
||||
// 문서 모달 열기
|
||||
@@ -442,6 +246,8 @@ export default function OrderDetailPage() {
|
||||
|
||||
// 상태별 버튼 표시 여부
|
||||
const showEditButton = order.status !== "shipped" && order.status !== "cancelled";
|
||||
// 수주 확정 버튼: 수주등록 상태에서만 표시
|
||||
const showConfirmButton = order.status === "order_registered";
|
||||
// 생산지시 생성 버튼: 출하완료, 취소, 생산지시완료 제외하고 표시
|
||||
// (수주등록, 수주확정, 생산중, 재작업중, 작업완료에서 표시)
|
||||
const showProductionCreateButton =
|
||||
@@ -473,6 +279,12 @@ export default function OrderDetailPage() {
|
||||
수정
|
||||
</Button>
|
||||
)}
|
||||
{showConfirmButton && (
|
||||
<Button onClick={handleConfirmOrder} className="bg-green-600 hover:bg-green-700">
|
||||
<CheckCircle2 className="h-4 w-4 mr-2" />
|
||||
수주 확정
|
||||
</Button>
|
||||
)}
|
||||
{showProductionCreateButton && (
|
||||
<Button onClick={handleProductionOrder}>
|
||||
<Factory className="h-4 w-4 mr-2" />
|
||||
@@ -770,9 +582,77 @@ export default function OrderDetailPage() {
|
||||
variant="outline"
|
||||
onClick={handleConfirmCancel}
|
||||
className="border-gray-300"
|
||||
disabled={isCancelling}
|
||||
>
|
||||
<XCircle className="h-4 w-4 mr-1" />
|
||||
취소 확정
|
||||
{isCancelling ? "취소 중..." : "취소 확정"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 수주 확정 다이얼로그 */}
|
||||
<Dialog open={isConfirmDialogOpen} onOpenChange={setIsConfirmDialogOpen}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<CheckCircle2 className="h-5 w-5 text-green-600" />
|
||||
수주 확정
|
||||
</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">
|
||||
<span className="text-muted-foreground">총금액</span>
|
||||
<span className="font-medium text-green-600">
|
||||
{formatAmount(order.totalAmount)}원
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-muted-foreground">현재 상태</span>
|
||||
{getOrderStatusBadge(order.status)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 확정 안내 */}
|
||||
<div className="bg-green-50 border border-green-200 rounded-lg p-4 text-sm space-y-1">
|
||||
<p className="font-medium mb-2 text-green-700">확정 후 변경사항</p>
|
||||
<ul className="space-y-1 text-green-600">
|
||||
<li>• 수주 상태가 '수주확정'으로 변경됩니다</li>
|
||||
<li>• 생산지시를 생성할 수 있습니다</li>
|
||||
<li>• 확정 후에도 수정이 가능합니다</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setIsConfirmDialogOpen(false)}
|
||||
>
|
||||
닫기
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleConfirmOrderSubmit}
|
||||
className="bg-green-600 hover:bg-green-700"
|
||||
disabled={isConfirming}
|
||||
>
|
||||
<CheckCircle2 className="h-4 w-4 mr-1" />
|
||||
{isConfirming ? "확정 중..." : "확정"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -10,9 +10,11 @@
|
||||
* - 스크린 품목 상세
|
||||
* - 모터/전장품 사양 (읽기전용)
|
||||
* - 절곡물 BOM
|
||||
*
|
||||
* API 연동: getOrderById, createProductionOrder
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useRouter, useParams } from "next/navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
@@ -25,7 +27,7 @@ import {
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Factory, ArrowLeft, BarChart3, CheckCircle2 } from "lucide-react";
|
||||
import { Factory, ArrowLeft, BarChart3, CheckCircle2, AlertCircle } from "lucide-react";
|
||||
import { PageLayout } from "@/components/organisms/PageLayout";
|
||||
import {
|
||||
AlertDialog,
|
||||
@@ -39,6 +41,15 @@ import {
|
||||
import { PageHeader } from "@/components/organisms/PageHeader";
|
||||
import { BadgeSm } from "@/components/atoms/BadgeSm";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
getOrderById,
|
||||
createProductionOrder,
|
||||
type Order,
|
||||
type CreateProductionOrderData,
|
||||
} from "@/components/orders/actions";
|
||||
import { getProcessList } from "@/components/process-management/actions";
|
||||
import type { Process } from "@/types/process";
|
||||
import { formatAmount } from "@/utils/formatAmount";
|
||||
|
||||
// 수주 정보 타입
|
||||
interface OrderInfo {
|
||||
@@ -47,7 +58,6 @@ interface OrderInfo {
|
||||
siteName: string;
|
||||
dueDate: string;
|
||||
itemCount: number;
|
||||
totalQuantity: string;
|
||||
creditGrade: string;
|
||||
status: string;
|
||||
}
|
||||
@@ -83,20 +93,17 @@ interface MaterialRequirement {
|
||||
status: "sufficient" | "insufficient";
|
||||
}
|
||||
|
||||
// 스크린 품목 상세 타입
|
||||
// 스크린 품목 상세 타입 (order.items에서 변환)
|
||||
interface ScreenItemDetail {
|
||||
no: number;
|
||||
itemName: string;
|
||||
location: string;
|
||||
openWidth: number;
|
||||
openHeight: number;
|
||||
productWidth: number;
|
||||
productHeight: number;
|
||||
guideRail: string;
|
||||
shaft: string;
|
||||
capacity: string;
|
||||
finish: string;
|
||||
specification: string;
|
||||
quantity: number;
|
||||
unit: string;
|
||||
unitPrice: number;
|
||||
supplyAmount: number;
|
||||
taxAmount: number;
|
||||
totalAmount: number;
|
||||
}
|
||||
|
||||
// 가이드레일 BOM 타입
|
||||
@@ -187,149 +194,134 @@ const PRIORITY_CONFIGS: PriorityConfig[] = [
|
||||
},
|
||||
];
|
||||
|
||||
// 샘플 수주 정보
|
||||
const SAMPLE_ORDER_INFO: OrderInfo = {
|
||||
orderNumber: "KD-TS-251217-09",
|
||||
client: "태영건설(주)",
|
||||
siteName: "데시앙 동탄 파크뷰",
|
||||
dueDate: "2026-02-25",
|
||||
itemCount: 3,
|
||||
totalQuantity: "3EA",
|
||||
creditGrade: "A (우량)",
|
||||
status: "재작업중",
|
||||
// 상태 레이블
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
order_registered: "수주등록",
|
||||
order_confirmed: "수주확정",
|
||||
production_ordered: "생산지시",
|
||||
in_production: "생산중",
|
||||
rework: "재작업중",
|
||||
work_completed: "작업완료",
|
||||
shipped: "출고완료",
|
||||
cancelled: "취소",
|
||||
};
|
||||
|
||||
// 샘플 작업지시 카드
|
||||
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. 포장"],
|
||||
},
|
||||
];
|
||||
// 품목과 공정 매칭 함수
|
||||
function matchItemToProcess(
|
||||
itemName: string,
|
||||
itemCode: string | undefined,
|
||||
processes: Process[]
|
||||
): Process | null {
|
||||
for (const process of processes) {
|
||||
for (const rule of process.classificationRules) {
|
||||
if (!rule.isActive) continue;
|
||||
|
||||
// 샘플 자재 소요량
|
||||
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",
|
||||
},
|
||||
];
|
||||
// 패턴 매칭 규칙
|
||||
if (rule.registrationType === "pattern") {
|
||||
let targetValue = "";
|
||||
if (rule.ruleType === "품목명") {
|
||||
targetValue = itemName;
|
||||
} else if (rule.ruleType === "품목코드" && itemCode) {
|
||||
targetValue = itemCode;
|
||||
}
|
||||
|
||||
// 샘플 스크린 품목 상세
|
||||
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,
|
||||
},
|
||||
];
|
||||
if (!targetValue) continue;
|
||||
|
||||
// 샘플 가이드레일 BOM
|
||||
const SAMPLE_GUIDE_RAIL_BOM: GuideRailBom[] = [
|
||||
{
|
||||
type: "백면형",
|
||||
spec: "120-70",
|
||||
code: "KSE01/KWE01",
|
||||
length: 3000,
|
||||
quantity: 6,
|
||||
},
|
||||
];
|
||||
let matched = false;
|
||||
switch (rule.matchingType) {
|
||||
case "startsWith":
|
||||
matched = targetValue.startsWith(rule.conditionValue);
|
||||
break;
|
||||
case "endsWith":
|
||||
matched = targetValue.endsWith(rule.conditionValue);
|
||||
break;
|
||||
case "contains":
|
||||
matched = targetValue.includes(rule.conditionValue);
|
||||
break;
|
||||
case "equals":
|
||||
matched = targetValue === rule.conditionValue;
|
||||
break;
|
||||
}
|
||||
|
||||
// 샘플 케이스(셔터박스) BOM
|
||||
const SAMPLE_CASE_BOM: CaseBom[] = [
|
||||
{ item: "케이스 본체", length: "L: 4000", quantity: 2 },
|
||||
{ item: "측면 덮개", length: "500-355", quantity: 6 },
|
||||
];
|
||||
if (matched) return process;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 샘플 하단 마감재 BOM
|
||||
const SAMPLE_BOTTOM_FINISH_BOM: BottomFinishBom[] = [
|
||||
{ item: "하단마감재", spec: "50-40", length: "L: 4000", quantity: 3 },
|
||||
];
|
||||
// 매칭되는 공정이 없으면 공정명으로 단순 매칭 시도
|
||||
// (예: 품목명에 "스크린"이 포함되면 "스크린" 공정 반환)
|
||||
for (const process of processes) {
|
||||
if (itemName.toLowerCase().includes(process.processName.toLowerCase())) {
|
||||
return process;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// 공정별 작업지시 그룹 타입
|
||||
interface ProcessWorkOrderGroup {
|
||||
process: Process;
|
||||
items: Array<{ itemName: string; itemCode?: string; quantity: number }>;
|
||||
}
|
||||
|
||||
// 수주 품목들을 공정별로 그룹핑
|
||||
function groupItemsByProcess(
|
||||
items: Array<{ itemName: string; itemCode?: string; quantity: number }>,
|
||||
processes: Process[]
|
||||
): ProcessWorkOrderGroup[] {
|
||||
if (items.length === 0 || processes.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// 공정별 품목 그룹 맵
|
||||
const processGroupMap = new Map<string, ProcessWorkOrderGroup>();
|
||||
// 매칭되지 않은 품목들
|
||||
const unmatchedItems: Array<{ itemName: string; itemCode?: string; quantity: number }> = [];
|
||||
|
||||
for (const item of items) {
|
||||
const matchedProcess = matchItemToProcess(
|
||||
item.itemName,
|
||||
item.itemCode,
|
||||
processes
|
||||
);
|
||||
|
||||
if (matchedProcess) {
|
||||
if (!processGroupMap.has(matchedProcess.id)) {
|
||||
processGroupMap.set(matchedProcess.id, {
|
||||
process: matchedProcess,
|
||||
items: [],
|
||||
});
|
||||
}
|
||||
processGroupMap.get(matchedProcess.id)!.items.push(item);
|
||||
} else {
|
||||
unmatchedItems.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
// 매칭되지 않은 품목이 있으면 "기타" 그룹 생성
|
||||
const groups = Array.from(processGroupMap.values());
|
||||
|
||||
if (unmatchedItems.length > 0) {
|
||||
// 기타 품목용 가상 공정 생성
|
||||
groups.push({
|
||||
process: {
|
||||
id: "unmatched",
|
||||
processName: "기타",
|
||||
processCode: "ETC",
|
||||
status: "사용중",
|
||||
description: "",
|
||||
classificationRules: [],
|
||||
workSteps: [],
|
||||
createdAt: "",
|
||||
updatedAt: "",
|
||||
} as Process,
|
||||
items: unmatchedItems,
|
||||
});
|
||||
}
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
export default function ProductionOrderCreatePage() {
|
||||
const router = useRouter();
|
||||
@@ -337,7 +329,9 @@ export default function ProductionOrderCreatePage() {
|
||||
const orderId = params.id as string;
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [orderInfo, setOrderInfo] = useState<OrderInfo | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [order, setOrder] = useState<Order | null>(null);
|
||||
const [processes, setProcesses] = useState<Process[]>([]);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
// 우선순위 상태
|
||||
@@ -346,37 +340,92 @@ export default function ProductionOrderCreatePage() {
|
||||
|
||||
// 성공 다이얼로그 상태
|
||||
const [showSuccessDialog, setShowSuccessDialog] = useState(false);
|
||||
const [generatedOrderNumber, setGeneratedOrderNumber] = useState("");
|
||||
const [generatedWorkOrders, setGeneratedWorkOrders] = useState<Array<{ workOrderNo: string; processName?: string }>>([]);
|
||||
|
||||
// 데이터 로드
|
||||
useEffect(() => {
|
||||
setTimeout(() => {
|
||||
setOrderInfo(SAMPLE_ORDER_INFO);
|
||||
// 수주 데이터 및 공정 목록 로드
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
// 수주 정보와 공정 목록을 병렬로 로드
|
||||
const [orderResult, processResult] = await Promise.all([
|
||||
getOrderById(orderId),
|
||||
getProcessList({ status: "사용중" }),
|
||||
]);
|
||||
|
||||
if (orderResult.success && orderResult.data) {
|
||||
setOrder(orderResult.data);
|
||||
} else {
|
||||
setError(orderResult.error || "수주 정보 조회에 실패했습니다.");
|
||||
}
|
||||
|
||||
if (processResult.success && processResult.data) {
|
||||
setProcesses(processResult.data.items);
|
||||
}
|
||||
} catch {
|
||||
setError("서버 오류가 발생했습니다.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}, 300);
|
||||
}
|
||||
}, [orderId]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
const handleCancel = () => {
|
||||
router.push(`/sales/order-management-sales/${orderId}`);
|
||||
router.push(`/ko/sales/order-management-sales/${orderId}`);
|
||||
};
|
||||
|
||||
const handleBackToDetail = () => {
|
||||
router.push(`/sales/order-management-sales/${orderId}`);
|
||||
router.push(`/ko/sales/order-management-sales/${orderId}`);
|
||||
};
|
||||
|
||||
const handleConfirm = async () => {
|
||||
if (!order) return;
|
||||
|
||||
setIsSubmitting(true);
|
||||
setError(null);
|
||||
try {
|
||||
// TODO: API 호출
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
// 공정별 품목 그룹핑 (processIds 추출을 위해)
|
||||
const groups = groupItemsByProcess(
|
||||
(order.items || []).map((item) => ({
|
||||
itemName: item.itemName,
|
||||
itemCode: item.itemCode,
|
||||
quantity: item.quantity,
|
||||
})),
|
||||
processes
|
||||
);
|
||||
|
||||
// 생산지시번호 생성 (실제로는 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}`;
|
||||
// 모든 매칭된 공정 ID 추출 (공정별로 각각 작업지시 생성)
|
||||
const matchedGroups = groups.filter((g) => g.process.id !== "unmatched");
|
||||
const allProcessIds = matchedGroups
|
||||
.map((g) => parseInt(g.process.id, 10))
|
||||
.filter((id) => !isNaN(id));
|
||||
|
||||
setGeneratedOrderNumber(newOrderNumber);
|
||||
setShowSuccessDialog(true);
|
||||
const productionData: CreateProductionOrderData = {
|
||||
priority: selectedPriority,
|
||||
memo: memo || undefined,
|
||||
processIds: allProcessIds.length > 0 ? allProcessIds : undefined,
|
||||
};
|
||||
|
||||
const result = await createProductionOrder(orderId, productionData);
|
||||
|
||||
if (result.success && result.data) {
|
||||
// 다중 작업지시 응답 처리
|
||||
const workOrders = result.data.workOrders || (result.data.workOrder ? [result.data.workOrder] : []);
|
||||
setGeneratedWorkOrders(
|
||||
workOrders.map((wo: { workOrderNo: string; process?: { processName: string } }) => ({
|
||||
workOrderNo: wo.workOrderNo,
|
||||
processName: wo.process?.processName,
|
||||
}))
|
||||
);
|
||||
setShowSuccessDialog(true);
|
||||
} else {
|
||||
setError(result.error || "생산지시 생성에 실패했습니다.");
|
||||
}
|
||||
} catch {
|
||||
setError("서버 오류가 발생했습니다.");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
@@ -384,9 +433,8 @@ export default function ProductionOrderCreatePage() {
|
||||
|
||||
const handleSuccessDialogClose = () => {
|
||||
setShowSuccessDialog(false);
|
||||
// 생산지시 상세 페이지로 이동 (실제로는 API 응답에서 받은 생산지시 ID 사용)
|
||||
// 임시로 PO-002 사용 (샘플 데이터와 매칭)
|
||||
router.push("/sales/order-management-sales/production-orders/PO-002");
|
||||
// 수주 상세 페이지로 이동 (상태가 변경되었으므로)
|
||||
router.push(`/ko/sales/order-management-sales/${orderId}`);
|
||||
};
|
||||
|
||||
// 선택된 우선순위 설정 가져오기
|
||||
@@ -404,7 +452,22 @@ export default function ProductionOrderCreatePage() {
|
||||
);
|
||||
}
|
||||
|
||||
if (!orderInfo) {
|
||||
if (error && !order) {
|
||||
return (
|
||||
<PageLayout>
|
||||
<div className="text-center py-12">
|
||||
<AlertCircle className="h-12 w-12 mx-auto mb-4 text-red-500" />
|
||||
<p className="text-muted-foreground">{error}</p>
|
||||
<Button variant="outline" onClick={handleCancel} className="mt-4">
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
돌아가기
|
||||
</Button>
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
if (!order) {
|
||||
return (
|
||||
<PageLayout>
|
||||
<div className="text-center py-12">
|
||||
@@ -419,7 +482,48 @@ export default function ProductionOrderCreatePage() {
|
||||
}
|
||||
|
||||
const selectedConfig = getSelectedPriorityConfig();
|
||||
const workOrderCount = SAMPLE_WORK_ORDER_CARDS.length;
|
||||
|
||||
// 공정별 품목 그룹핑
|
||||
const allGroups = groupItemsByProcess(
|
||||
(order.items || []).map((item) => ({
|
||||
itemName: item.itemName,
|
||||
itemCode: item.itemCode,
|
||||
quantity: item.quantity,
|
||||
})),
|
||||
processes
|
||||
);
|
||||
|
||||
// 공정 매칭된 그룹과 기타(미분류) 그룹 분리
|
||||
const processGroups = allGroups.filter((g) => g.process.id !== "unmatched");
|
||||
const unmatchedGroup = allGroups.find((g) => g.process.id === "unmatched");
|
||||
|
||||
// 작업지시 수 = 공정 매칭된 그룹 수만 (기타 제외)
|
||||
const workOrderCount = processGroups.length;
|
||||
|
||||
// order.items에서 스크린 품목 상세 데이터 변환
|
||||
const screenItems: ScreenItemDetail[] = (order.items || []).map((item, index) => ({
|
||||
no: item.serialNo || index + 1,
|
||||
itemName: item.itemName,
|
||||
specification: item.specification || "-",
|
||||
quantity: item.quantity,
|
||||
unit: item.unit || "EA",
|
||||
unitPrice: item.unitPrice,
|
||||
supplyAmount: item.supplyAmount,
|
||||
taxAmount: item.taxAmount,
|
||||
totalAmount: item.totalAmount,
|
||||
}));
|
||||
|
||||
// Order에서 UI에 표시할 데이터 변환
|
||||
const orderInfo = {
|
||||
orderNumber: order.lotNumber,
|
||||
client: order.client,
|
||||
siteName: order.siteName,
|
||||
dueDate: order.expectedShipDate || "-",
|
||||
itemCount: order.itemCount,
|
||||
creditGrade: "B (관리)", // API에서 제공하지 않아 기본값 사용
|
||||
status: STATUS_LABELS[order.status] || order.status,
|
||||
amount: order.amount,
|
||||
};
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
@@ -448,6 +552,17 @@ export default function ProductionOrderCreatePage() {
|
||||
}
|
||||
/>
|
||||
|
||||
{/* 에러 메시지 표시 */}
|
||||
{error && order && (
|
||||
<div className="mb-4 p-4 bg-red-50 border border-red-200 rounded-lg flex items-start gap-3">
|
||||
<AlertCircle className="h-5 w-5 text-red-500 flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-red-800">생산지시 생성 실패</p>
|
||||
<p className="text-sm text-red-600 mt-1">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* 수주 정보 */}
|
||||
<Card>
|
||||
@@ -476,10 +591,6 @@ export default function ProductionOrderCreatePage() {
|
||||
<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">
|
||||
@@ -601,98 +712,100 @@ export default function ProductionOrderCreatePage() {
|
||||
</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>
|
||||
{processGroups.length > 0 ? (
|
||||
processGroups.map((group, groupIdx) => (
|
||||
<div
|
||||
key={group.process.id}
|
||||
className="border rounded-lg p-4 bg-blue-50/50 border-blue-200"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<BadgeSm className="bg-blue-100 text-blue-700 border-blue-200">
|
||||
{group.process.processName}
|
||||
</BadgeSm>
|
||||
<span className="font-mono text-sm font-medium">
|
||||
{order.lotNumber}-{String(groupIdx + 1).padStart(2, "0")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
<p className="text-sm text-muted-foreground">품목 수</p>
|
||||
<p className="font-medium">{card.itemCount}건</p>
|
||||
<p className="font-medium">{group.items.length}건</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">총 수량</p>
|
||||
<p className="font-medium">{card.totalQuantity}</p>
|
||||
<p className="text-sm text-muted-foreground mb-2">공정 순서</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{group.process.workSteps.length > 0 ? (
|
||||
group.process.workSteps.map((step, idx) => (
|
||||
<BadgeSm
|
||||
key={idx}
|
||||
className="bg-gray-50 text-gray-600 border-gray-200"
|
||||
>
|
||||
{idx + 1}. {step}
|
||||
</BadgeSm>
|
||||
))
|
||||
) : (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
공정관리에서 세부 작업단계를 등록해 주세요.
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</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 className="border rounded-lg p-4 bg-gray-50/50 border-gray-200">
|
||||
<div className="text-center py-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
매칭되는 공정이 없습니다. 공정관리에서 분류규칙을 등록해 주세요.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 자재 소요량 및 재고 현황 */}
|
||||
{/* 기타 품목 (공정 미매칭) */}
|
||||
{unmatchedGroup && unmatchedGroup.items.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
기타 품목
|
||||
<BadgeSm className="bg-gray-100 text-gray-600 border-gray-200">
|
||||
{unmatchedGroup.items.length}건
|
||||
</BadgeSm>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground mb-3">
|
||||
공정에 매칭되지 않은 품목입니다. 출하 시 별도로 준비해 주세요.
|
||||
</p>
|
||||
<ul className="space-y-1">
|
||||
{unmatchedGroup.items.map((item, idx) => {
|
||||
const qty = Number(item.quantity);
|
||||
return (
|
||||
<li key={idx} className="text-sm flex items-center gap-2">
|
||||
<span className="text-muted-foreground">•</span>
|
||||
<span>{item.itemName}</span>
|
||||
<span className="text-muted-foreground">
|
||||
({Number.isInteger(qty) ? qty : qty.toFixed(2).replace(/\.?0+$/, "")}개)
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 자재 소요량 및 재고 현황 - 추후 BOM API 연동 예정 */}
|
||||
<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 className="text-center py-8 text-muted-foreground">
|
||||
<p>BOM 데이터 연동 후 자재 소요량이 표시됩니다.</p>
|
||||
<p className="text-sm mt-1">(추후 제공 예정)</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -700,7 +813,7 @@ export default function ProductionOrderCreatePage() {
|
||||
{/* 스크린 품목 상세 */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">스크린 품목 상세 ({SAMPLE_SCREEN_ITEMS.length}건)</CardTitle>
|
||||
<CardTitle className="text-base">스크린 품목 상세 ({screenItems.length}건)</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="border rounded-lg overflow-x-auto">
|
||||
@@ -709,37 +822,39 @@ export default function ProductionOrderCreatePage() {
|
||||
<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>규격</TableHead>
|
||||
<TableHead className="text-center">수량</TableHead>
|
||||
<TableHead className="text-center">단위</TableHead>
|
||||
<TableHead className="text-right">단가</TableHead>
|
||||
<TableHead className="text-right">공급가</TableHead>
|
||||
<TableHead className="text-right">세액</TableHead>
|
||||
<TableHead className="text-right">합계</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")}
|
||||
{screenItems.length > 0 ? (
|
||||
screenItems.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.specification}</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">{formatAmount(item.supplyAmount)}</TableCell>
|
||||
<TableCell className="text-right">{formatAmount(item.taxAmount)}</TableCell>
|
||||
<TableCell className="text-right font-medium">{formatAmount(item.totalAmount)}</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={9} className="text-center py-8 text-muted-foreground">
|
||||
품목 정보가 없습니다.
|
||||
</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>
|
||||
@@ -771,91 +886,15 @@ export default function ProductionOrderCreatePage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 절곡물 BOM */}
|
||||
{/* 절곡물 BOM - 추후 BOM API 연동 예정 */}
|
||||
<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>
|
||||
<CardContent>
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
<p>BOM 데이터 연동 후 절곡물 정보가 표시됩니다.</p>
|
||||
<p className="text-sm mt-1">(가이드레일, 케이스, 하단 마감재 - 추후 제공 예정)</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -882,7 +921,7 @@ export default function ProductionOrderCreatePage() {
|
||||
</Button>
|
||||
<Button onClick={handleConfirm} disabled={isSubmitting}>
|
||||
<BarChart3 className="h-4 w-4 mr-2" />
|
||||
생산지시 확정 ({SAMPLE_SCREEN_ITEMS.length}건)
|
||||
생산지시 확정 ({screenItems.length}건)
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -894,16 +933,27 @@ export default function ProductionOrderCreatePage() {
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle className="flex items-center gap-2">
|
||||
<CheckCircle2 className="h-5 w-5 text-green-600" />
|
||||
생산지시가 생성되었습니다.
|
||||
작업지시가 {generatedWorkOrders.length}건 생성되었습니다.
|
||||
</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>
|
||||
<p className="text-sm text-muted-foreground mb-2">생성된 작업지시:</p>
|
||||
<ul className="space-y-1">
|
||||
{generatedWorkOrders.map((wo, idx) => (
|
||||
<li key={idx} className="flex items-center gap-2">
|
||||
<span className="font-mono font-semibold text-foreground">{wo.workOrderNo}</span>
|
||||
{wo.processName && (
|
||||
<BadgeSm className="bg-blue-100 text-blue-700 border-blue-200">
|
||||
{wo.processName}
|
||||
</BadgeSm>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
생산관리 > 생산지시 관리에서 작업지시서를 생성하세요.
|
||||
생산관리 > 작업지시 관리에서 확인하세요.
|
||||
</p>
|
||||
</div>
|
||||
</AlertDialogDescription>
|
||||
|
||||
@@ -2,10 +2,11 @@
|
||||
|
||||
/**
|
||||
* 수주 등록 페이지
|
||||
* API 연동 완료 (2025-01-08)
|
||||
*/
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { OrderRegistration, OrderFormData } from "@/components/orders";
|
||||
import { OrderRegistration, OrderFormData, createOrder } from "@/components/orders";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export default function OrderNewPage() {
|
||||
@@ -16,14 +17,19 @@ export default function OrderNewPage() {
|
||||
};
|
||||
|
||||
const handleSave = async (formData: OrderFormData) => {
|
||||
// TODO: API 연동
|
||||
console.log("수주 등록 데이터:", formData);
|
||||
try {
|
||||
const result = await createOrder(formData);
|
||||
|
||||
// 임시: 성공 시뮬레이션
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
|
||||
toast.success("수주가 등록되었습니다.");
|
||||
router.push("/sales/order-management-sales");
|
||||
if (result.success) {
|
||||
toast.success("수주가 등록되었습니다.");
|
||||
router.push("/sales/order-management-sales");
|
||||
} else {
|
||||
toast.error(result.error || "수주 등록에 실패했습니다.");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error creating order:", error);
|
||||
toast.error("수주 등록 중 오류가 발생했습니다.");
|
||||
}
|
||||
};
|
||||
|
||||
return <OrderRegistration onBack={handleBack} onSave={handleSave} />;
|
||||
|
||||
@@ -7,9 +7,10 @@
|
||||
* - 상단 통계 카드: 이번 달 수주, 분할 대기, 생산지시 대기, 출하 대기
|
||||
* - 필터 탭: 전체, 수주등록, 수주확정, 생산지시완료, 미수
|
||||
* - 완전한 반응형 지원
|
||||
* - API 연동 완료 (2025-01-08)
|
||||
*/
|
||||
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { useState, useRef, useEffect, useCallback, useTransition } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
FileText,
|
||||
@@ -20,6 +21,8 @@ import {
|
||||
ClipboardList,
|
||||
Truck,
|
||||
Eye,
|
||||
Loader2,
|
||||
Bell,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
@@ -31,11 +34,7 @@ import {
|
||||
} 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";
|
||||
@@ -51,149 +50,18 @@ import {
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import {
|
||||
getOrders,
|
||||
getOrderStats,
|
||||
deleteOrder,
|
||||
deleteOrders,
|
||||
type Order,
|
||||
type OrderStatus,
|
||||
type OrderStats,
|
||||
} from "@/components/orders/actions";
|
||||
import { sendSalesOrderNotification } from "@/lib/actions/fcm";
|
||||
import { isNextRedirectError } from "@/lib/utils/redirect-error";
|
||||
|
||||
// 수주 상태 타입
|
||||
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) {
|
||||
@@ -218,6 +86,7 @@ function getOrderStatusBadge(status: OrderStatus) {
|
||||
|
||||
export default function OrderManagementSalesPage() {
|
||||
const router = useRouter();
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [filterType, setFilterType] = useState("all");
|
||||
const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set());
|
||||
@@ -236,8 +105,42 @@ export default function OrderManagementSalesPage() {
|
||||
const [mobileDisplayCount, setMobileDisplayCount] = useState(20);
|
||||
const sentinelRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// 로컬 데이터 state (실제 구현에서는 API 연동)
|
||||
const [orders, setOrders] = useState<Order[]>(SAMPLE_ORDERS);
|
||||
// API 연동 state
|
||||
const [orders, setOrders] = useState<Order[]>([]);
|
||||
const [apiStats, setApiStats] = useState<OrderStats | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
|
||||
// 데이터 로드 함수
|
||||
const loadData = useCallback(async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const [ordersResult, statsResult] = await Promise.all([
|
||||
getOrders(),
|
||||
getOrderStats(),
|
||||
]);
|
||||
|
||||
if (ordersResult.success && ordersResult.data) {
|
||||
setOrders(ordersResult.data.items);
|
||||
} else {
|
||||
toast.error(ordersResult.error || "수주 목록을 불러오는데 실패했습니다.");
|
||||
}
|
||||
|
||||
if (statsResult.success && statsResult.data) {
|
||||
setApiStats(statsResult.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error loading orders:", error);
|
||||
toast.error("데이터를 불러오는 중 오류가 발생했습니다.");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 초기 데이터 로드
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [loadData]);
|
||||
|
||||
// 필터링 및 정렬
|
||||
const filteredOrders = orders
|
||||
@@ -313,7 +216,7 @@ export default function OrderManagementSalesPage() {
|
||||
setMobileDisplayCount(20);
|
||||
}, [searchTerm, filterType]);
|
||||
|
||||
// 통계 계산
|
||||
// 통계 계산 (API stats 우선 사용, 없으면 로컬 계산)
|
||||
const now = new Date();
|
||||
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
|
||||
@@ -321,18 +224,18 @@ export default function OrderManagementSalesPage() {
|
||||
const thisMonthOrders = orders.filter(
|
||||
(o) => new Date(o.orderDate) >= startOfMonth
|
||||
);
|
||||
const thisMonthAmount = thisMonthOrders.reduce((sum, o) => sum + o.amount, 0);
|
||||
const thisMonthAmount = apiStats?.thisMonthAmount ?? thisMonthOrders.reduce((sum, o) => sum + o.amount, 0);
|
||||
|
||||
// 분할 대기 (예시: 수주확정 상태)
|
||||
const splitPendingCount = orders.filter((o) => o.status === "order_confirmed").length;
|
||||
const splitPendingCount = apiStats?.splitPending ?? orders.filter((o) => o.status === "order_confirmed").length;
|
||||
|
||||
// 생산지시 대기 (수주확정 상태 중 생산지시 안된 것)
|
||||
const productionPendingCount = orders.filter(
|
||||
const productionPendingCount = apiStats?.productionPending ?? orders.filter(
|
||||
(o) => o.status === "order_confirmed" || o.status === "order_registered"
|
||||
).length;
|
||||
|
||||
// 출하 대기 (작업완료 상태)
|
||||
const shipPendingCount = orders.filter((o) => o.status === "work_completed").length;
|
||||
const shipPendingCount = apiStats?.shipPending ?? orders.filter((o) => o.status === "work_completed").length;
|
||||
|
||||
const stats = [
|
||||
{
|
||||
@@ -370,7 +273,8 @@ export default function OrderManagementSalesPage() {
|
||||
router.push(`/sales/order-management-sales/${order.id}/edit`);
|
||||
};
|
||||
|
||||
const handleCancel = (orderId: string) => {
|
||||
// 개별 취소 기능은 상세 페이지에서 처리
|
||||
const _handleCancel = (orderId: string) => {
|
||||
setCancelTargetId(orderId);
|
||||
setIsCancelDialogOpen(true);
|
||||
};
|
||||
@@ -399,25 +303,72 @@ export default function OrderManagementSalesPage() {
|
||||
|
||||
// 다중 선택 삭제 (IntegratedListTemplateV2에서 확인 후 호출됨)
|
||||
// 템플릿 내부에서 이미 확인 팝업을 처리하므로 바로 삭제 실행
|
||||
const handleBulkDelete = () => {
|
||||
const handleBulkDelete = async () => {
|
||||
const selectedIds = Array.from(selectedItems);
|
||||
if (selectedIds.length > 0) {
|
||||
setOrders(orders.filter((o) => !selectedIds.includes(o.id)));
|
||||
setSelectedItems(new Set());
|
||||
toast.success(`${selectedIds.length}개의 수주가 삭제되었습니다.`);
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
const result = await deleteOrders(selectedIds);
|
||||
if (result.success) {
|
||||
setOrders(orders.filter((o) => !selectedIds.includes(o.id)));
|
||||
setSelectedItems(new Set());
|
||||
toast.success(`${selectedIds.length}개의 수주가 삭제되었습니다.`);
|
||||
// 통계 새로고침
|
||||
const statsResult = await getOrderStats();
|
||||
if (statsResult.success && statsResult.data) {
|
||||
setApiStats(statsResult.data);
|
||||
}
|
||||
} else {
|
||||
toast.error(result.error || "삭제에 실패했습니다.");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error deleting orders:", error);
|
||||
toast.error("삭제 중 오류가 발생했습니다.");
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 삭제 확정 (단일/다중 모두 처리)
|
||||
const handleConfirmDelete = () => {
|
||||
const handleConfirmDelete = async () => {
|
||||
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([]);
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
let success = false;
|
||||
if (count === 1) {
|
||||
const result = await deleteOrder(deleteTargetIds[0]);
|
||||
success = result.success;
|
||||
if (!success) {
|
||||
toast.error(result.error || "삭제에 실패했습니다.");
|
||||
}
|
||||
} else {
|
||||
const result = await deleteOrders(deleteTargetIds);
|
||||
success = result.success;
|
||||
if (!success) {
|
||||
toast.error(result.error || "삭제에 실패했습니다.");
|
||||
}
|
||||
}
|
||||
|
||||
if (success) {
|
||||
setOrders(orders.filter((o) => !deleteTargetIds.includes(o.id)));
|
||||
setSelectedItems(new Set());
|
||||
toast.success(`${count}개의 수주가 삭제되었습니다.`);
|
||||
// 통계 새로고침
|
||||
const statsResult = await getOrderStats();
|
||||
if (statsResult.success && statsResult.data) {
|
||||
setApiStats(statsResult.data);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error deleting orders:", error);
|
||||
toast.error("삭제 중 오류가 발생했습니다.");
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
setIsDeleteDialogOpen(false);
|
||||
setDeleteTargetIds([]);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -443,6 +394,23 @@ export default function OrderManagementSalesPage() {
|
||||
}
|
||||
};
|
||||
|
||||
// FCM 알림 발송 핸들러
|
||||
const handleSendNotification = useCallback(async () => {
|
||||
startTransition(async () => {
|
||||
try {
|
||||
const result = await sendSalesOrderNotification();
|
||||
if (result.success) {
|
||||
toast.success(`수주완료 알림을 발송했습니다. (${result.sentCount || 0}건)`);
|
||||
} else {
|
||||
toast.error(result.error || "알림 발송에 실패했습니다.");
|
||||
}
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
toast.error("알림 발송 중 오류가 발생했습니다.");
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
// 탭 구성
|
||||
const tabs: TabOption[] = [
|
||||
{
|
||||
@@ -654,6 +622,18 @@ export default function OrderManagementSalesPage() {
|
||||
);
|
||||
};
|
||||
|
||||
// 로딩 상태 표시
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-blue-600" />
|
||||
<p className="text-muted-foreground">수주 목록을 불러오는 중...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<IntegratedListTemplateV2
|
||||
@@ -661,10 +641,16 @@ export default function OrderManagementSalesPage() {
|
||||
description="수주 관리 및 생산지시 연동"
|
||||
icon={FileText}
|
||||
headerActions={
|
||||
<Button className="ml-auto" onClick={() => router.push("/sales/order-management-sales/new")}>
|
||||
<FileText className="w-4 h-4 mr-2" />
|
||||
수주 등록
|
||||
</Button>
|
||||
<div className="flex gap-2 ml-auto">
|
||||
<Button variant="outline" onClick={handleSendNotification} disabled={isPending}>
|
||||
<Bell className="w-4 h-4 mr-2" />
|
||||
수주완료
|
||||
</Button>
|
||||
<Button onClick={() => router.push("/sales/order-management-sales/new")}>
|
||||
<FileText className="w-4 h-4 mr-2" />
|
||||
수주 등록
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
stats={stats}
|
||||
searchValue={searchTerm}
|
||||
@@ -749,13 +735,18 @@ export default function OrderManagementSalesPage() {
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>취소</AlertDialogCancel>
|
||||
<AlertDialogCancel disabled={isDeleting}>취소</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleConfirmDelete}
|
||||
disabled={isDeleting}
|
||||
className="bg-gray-900 hover:bg-gray-800 text-white gap-2"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
삭제
|
||||
{isDeleting ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="h-4 w-4" />
|
||||
)}
|
||||
{isDeleting ? "삭제 중..." : "삭제"}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
|
||||
@@ -47,6 +47,9 @@ import {
|
||||
MessageCircle,
|
||||
X,
|
||||
FileCheck,
|
||||
Package,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
} from "lucide-react";
|
||||
import { ContentLoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
|
||||
@@ -69,6 +72,9 @@ export default function QuoteDetailPage() {
|
||||
const [showDetailedBreakdown, setShowDetailedBreakdown] = useState(true);
|
||||
const [showMaterialList, setShowMaterialList] = useState(true);
|
||||
|
||||
// BOM 자재 상세 펼침/접힘 상태
|
||||
const [isBomExpanded, setIsBomExpanded] = useState(true);
|
||||
|
||||
// 견적 데이터 조회
|
||||
const fetchQuote = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
@@ -464,6 +470,84 @@ export default function QuoteDetailPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* BOM 자재 상세 */}
|
||||
{quote.bomMaterials && quote.bomMaterials.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<Package className="h-5 w-5" />
|
||||
BOM 자재 상세
|
||||
<Badge variant="secondary" className="ml-2">
|
||||
{quote.bomMaterials.length}개 품목
|
||||
</Badge>
|
||||
</CardTitle>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setIsBomExpanded(!isBomExpanded)}
|
||||
>
|
||||
{isBomExpanded ? (
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
{isBomExpanded && (
|
||||
<CardContent>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/50">
|
||||
<th className="text-left p-2 font-medium">No</th>
|
||||
<th className="text-left p-2 font-medium">품목코드</th>
|
||||
<th className="text-left p-2 font-medium">품목명</th>
|
||||
<th className="text-left p-2 font-medium">유형</th>
|
||||
<th className="text-left p-2 font-medium">규격</th>
|
||||
<th className="text-center p-2 font-medium">단위</th>
|
||||
<th className="text-right p-2 font-medium">수량</th>
|
||||
<th className="text-right p-2 font-medium">단가</th>
|
||||
<th className="text-right p-2 font-medium">금액</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{quote.bomMaterials.map((material, index) => (
|
||||
<tr key={index} className="border-b hover:bg-muted/30">
|
||||
<td className="p-2 text-muted-foreground">{index + 1}</td>
|
||||
<td className="p-2 font-mono text-xs">{material.itemCode}</td>
|
||||
<td className="p-2">{material.itemName}</td>
|
||||
<td className="p-2">
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{material.itemType === 'RM' ? '원자재' :
|
||||
material.itemType === 'SM' ? '부자재' :
|
||||
material.itemType === 'CS' ? '소모품' : material.itemType}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="p-2 text-muted-foreground">{material.specification || '-'}</td>
|
||||
<td className="p-2 text-center">{material.unit}</td>
|
||||
<td className="p-2 text-right">{material.quantity.toLocaleString()}</td>
|
||||
<td className="p-2 text-right">₩{material.unitPrice.toLocaleString()}</td>
|
||||
<td className="p-2 text-right font-medium">₩{material.totalPrice.toLocaleString()}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr className="border-t-2 bg-muted/30">
|
||||
<td colSpan={8} className="p-2 text-right font-medium">합계</td>
|
||||
<td className="p-2 text-right font-bold text-blue-600">
|
||||
₩{quote.bomMaterials.reduce((sum, m) => sum + m.totalPrice, 0).toLocaleString()}
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 견적서 다이얼로그 */}
|
||||
<Dialog open={isQuoteDocumentOpen} onOpenChange={setIsQuoteDocumentOpen}>
|
||||
<DialogContent className="max-w-[95vw] md:max-w-[800px] lg:max-w-[900px] h-[95vh] flex flex-col p-0">
|
||||
|
||||
@@ -75,11 +75,13 @@ export function ApprovalLineSection({ data, onChange }: ApprovalLineSectionProps
|
||||
onValueChange={(value) => handleChange(index, value)}
|
||||
>
|
||||
<SelectTrigger className="flex-1">
|
||||
<SelectValue placeholder="부서명 / 직책명 / 이름 ▼">
|
||||
{person.name && !person.id.startsWith('temp-')
|
||||
? `${person.department || ''} / ${person.position || ''} / ${person.name}`
|
||||
: null}
|
||||
</SelectValue>
|
||||
<SelectValue
|
||||
placeholder={
|
||||
person.name && !person.id.startsWith('temp-')
|
||||
? `${person.department || ''} / ${person.position || ''} / ${person.name}`
|
||||
: "부서명 / 직책명 / 이름 ▼"
|
||||
}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{employees.map((employee) => (
|
||||
|
||||
@@ -75,11 +75,13 @@ export function ReferenceSection({ data, onChange }: ReferenceSectionProps) {
|
||||
onValueChange={(value) => handleChange(index, value)}
|
||||
>
|
||||
<SelectTrigger className="flex-1">
|
||||
<SelectValue placeholder="부서명 / 직책명 / 이름 ▼">
|
||||
{person.name && !person.id.startsWith('temp-')
|
||||
? `${person.department || ''} / ${person.position || ''} / ${person.name}`
|
||||
: null}
|
||||
</SelectValue>
|
||||
<SelectValue
|
||||
placeholder={
|
||||
person.name && !person.id.startsWith('temp-')
|
||||
? `${person.department || ''} / ${person.position || ''} / ${person.name}`
|
||||
: "부서명 / 직책명 / 이름 ▼"
|
||||
}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{employees.map((employee) => (
|
||||
|
||||
@@ -457,4 +457,4 @@ export async function cancelDraft(id: string): Promise<{ success: boolean; error
|
||||
error: '서버 오류가 발생했습니다.',
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
Trash2,
|
||||
Plus,
|
||||
Pencil,
|
||||
Bell,
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
@@ -19,6 +20,7 @@ import {
|
||||
submitDraft,
|
||||
submitDrafts,
|
||||
} from './actions';
|
||||
import { sendApprovalNotification } from '@/lib/actions/fcm';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@@ -243,6 +245,24 @@ export function DraftBox() {
|
||||
router.push('/ko/approval/draft/new');
|
||||
}, [router]);
|
||||
|
||||
// ===== FCM 알림 발송 핸들러 =====
|
||||
const handleSendNotification = useCallback(async () => {
|
||||
startTransition(async () => {
|
||||
try {
|
||||
const result = await sendApprovalNotification();
|
||||
if (result.success) {
|
||||
toast.success(`결재 알림을 발송했습니다. (${result.sentCount || 0}건)`);
|
||||
} else {
|
||||
toast.error(result.error || '알림 발송에 실패했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('Notification error:', error);
|
||||
toast.error('알림 발송 중 오류가 발생했습니다.');
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
// ===== 문서 클릭/수정 핸들러 (조건부 로직) =====
|
||||
// 임시저장 → 문서 작성 페이지 (수정 모드)
|
||||
// 그 외 → 문서 상세 모달 (상세 API 호출하여 content 포함된 데이터 가져옴)
|
||||
@@ -597,6 +617,10 @@ export function DraftBox() {
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Button variant="outline" onClick={handleSendNotification}>
|
||||
<Bell className="h-4 w-4 mr-2" />
|
||||
문서완료
|
||||
</Button>
|
||||
<Button onClick={handleNewDocument}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
문서 작성
|
||||
|
||||
@@ -1,35 +1,79 @@
|
||||
'use server';
|
||||
|
||||
import type { Category } from './types';
|
||||
import { apiClient } from '@/lib/api';
|
||||
|
||||
// ===== 목데이터 (추후 API 연동 시 교체) =====
|
||||
let mockCategories: Category[] = [
|
||||
{ id: '1', name: '슬라이드 OPEN 사이즈', order: 1, isDefault: true },
|
||||
{ id: '2', name: '모터', order: 2, isDefault: true },
|
||||
{ id: '3', name: '공정자재', order: 3, isDefault: true },
|
||||
{ id: '4', name: '철물', order: 4, isDefault: true },
|
||||
];
|
||||
/**
|
||||
* 주일 기업 - 카테고리 관리 Server Actions
|
||||
* 표준화된 apiClient 사용 버전
|
||||
*/
|
||||
|
||||
// 다음 ID 생성
|
||||
let nextId = 5;
|
||||
// ========================================
|
||||
// API 응답 타입
|
||||
// ========================================
|
||||
|
||||
// ===== 카테고리 목록 조회 =====
|
||||
interface ApiCategory {
|
||||
id: number;
|
||||
name: string;
|
||||
sort_order: number;
|
||||
is_default: boolean;
|
||||
is_active: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 타입 변환 함수
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* API 응답 → Category 타입 변환
|
||||
*/
|
||||
function transformCategory(apiData: ApiCategory): Category {
|
||||
return {
|
||||
id: String(apiData.id),
|
||||
name: apiData.name || '',
|
||||
order: apiData.sort_order || 0,
|
||||
isDefault: apiData.is_default || false,
|
||||
isActive: apiData.is_active !== false,
|
||||
createdAt: apiData.created_at || '',
|
||||
updatedAt: apiData.updated_at || '',
|
||||
};
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// API 함수
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* 카테고리 목록 조회
|
||||
* GET /api/v1/categories
|
||||
*/
|
||||
export async function getCategories(): Promise<{
|
||||
success: boolean;
|
||||
data?: Category[];
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
// 목데이터 반환 (순서대로 정렬)
|
||||
const sortedCategories = [...mockCategories].sort((a, b) => a.order - b.order);
|
||||
return { success: true, data: sortedCategories };
|
||||
const response = await apiClient.get<{
|
||||
data: ApiCategory[];
|
||||
}>('/categories', { params: { per_page: '100' } });
|
||||
|
||||
const categories = (response.data || [])
|
||||
.map(transformCategory)
|
||||
.sort((a, b) => a.order - b.order);
|
||||
|
||||
return { success: true, data: categories };
|
||||
} catch (error) {
|
||||
console.error('[getCategories] Error:', error);
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
console.error('카테고리 목록 조회 오류:', error);
|
||||
return { success: false, error: '카테고리 목록을 불러오는데 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 카테고리 생성 =====
|
||||
/**
|
||||
* 카테고리 생성
|
||||
* POST /api/v1/categories
|
||||
*/
|
||||
export async function createCategory(data: {
|
||||
name: string;
|
||||
}): Promise<{
|
||||
@@ -38,22 +82,20 @@ export async function createCategory(data: {
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
const newCategory: Category = {
|
||||
id: String(nextId++),
|
||||
const response = await apiClient.post<ApiCategory>('/categories', {
|
||||
name: data.name,
|
||||
order: mockCategories.length + 1,
|
||||
isDefault: false,
|
||||
};
|
||||
|
||||
mockCategories.push(newCategory);
|
||||
return { success: true, data: newCategory };
|
||||
});
|
||||
return { success: true, data: transformCategory(response) };
|
||||
} catch (error) {
|
||||
console.error('[createCategory] Error:', error);
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
console.error('카테고리 생성 오류:', error);
|
||||
return { success: false, error: '카테고리 생성에 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 카테고리 수정 =====
|
||||
/**
|
||||
* 카테고리 수정
|
||||
* PATCH /api/v1/categories/{id}
|
||||
*/
|
||||
export async function updateCategory(
|
||||
id: string,
|
||||
data: { name?: string }
|
||||
@@ -63,65 +105,64 @@ export async function updateCategory(
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
const index = mockCategories.findIndex(c => c.id === id);
|
||||
if (index === -1) {
|
||||
return { success: false, error: '카테고리를 찾을 수 없습니다.' };
|
||||
}
|
||||
|
||||
mockCategories[index] = {
|
||||
...mockCategories[index],
|
||||
...data,
|
||||
};
|
||||
|
||||
return { success: true, data: mockCategories[index] };
|
||||
const response = await apiClient.patch<ApiCategory>(`/categories/${id}`, {
|
||||
name: data.name,
|
||||
});
|
||||
return { success: true, data: transformCategory(response) };
|
||||
} catch (error) {
|
||||
console.error('[updateCategory] Error:', error);
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
console.error('카테고리 수정 오류:', error);
|
||||
return { success: false, error: '카테고리 수정에 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 카테고리 삭제 =====
|
||||
/**
|
||||
* 카테고리 삭제
|
||||
* DELETE /api/v1/categories/{id}
|
||||
*/
|
||||
export async function deleteCategory(id: string): Promise<{
|
||||
success: boolean;
|
||||
error?: string;
|
||||
errorType?: 'IN_USE' | 'DEFAULT' | 'GENERAL';
|
||||
}> {
|
||||
try {
|
||||
const category = mockCategories.find(c => c.id === id);
|
||||
await apiClient.delete(`/categories/${id}`);
|
||||
return { success: true };
|
||||
} catch (error: unknown) {
|
||||
console.error('카테고리 삭제 오류:', error);
|
||||
|
||||
if (!category) {
|
||||
return { success: false, error: '카테고리를 찾을 수 없습니다.', errorType: 'GENERAL' };
|
||||
}
|
||||
// API 에러 응답에서 errorType 추출
|
||||
const apiError = error as { response?: { data?: { error_type?: string; message?: string } } };
|
||||
const errorType = apiError?.response?.data?.error_type;
|
||||
const errorMessage = apiError?.response?.data?.message;
|
||||
|
||||
// 기본 카테고리는 삭제 불가
|
||||
if (category.isDefault) {
|
||||
if (errorType === 'IN_USE') {
|
||||
return {
|
||||
success: false,
|
||||
error: '기본 카테고리는 삭제가 불가합니다.',
|
||||
errorType: 'DEFAULT'
|
||||
error: errorMessage || '해당 카테고리를 사용하고 있는 품목이 있습니다.',
|
||||
errorType: 'IN_USE',
|
||||
};
|
||||
}
|
||||
|
||||
// TODO: 품목 사용 여부 체크 로직 (추후 API 연동 시)
|
||||
// 현재는 목데이터이므로 사용 중인 품목이 없다고 가정
|
||||
// const itemsUsingCategory = await checkItemsUsingCategory(id);
|
||||
// if (itemsUsingCategory.length > 0) {
|
||||
// return {
|
||||
// success: false,
|
||||
// error: `"${category.name}"을(를) 사용하고 있는 품목이 있습니다. 모두 변경 후 삭제가 가능합니다.`,
|
||||
// errorType: 'IN_USE'
|
||||
// };
|
||||
// }
|
||||
if (errorType === 'DEFAULT') {
|
||||
return {
|
||||
success: false,
|
||||
error: errorMessage || '기본 카테고리는 삭제가 불가합니다.',
|
||||
errorType: 'DEFAULT',
|
||||
};
|
||||
}
|
||||
|
||||
mockCategories = mockCategories.filter(c => c.id !== id);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('[deleteCategory] Error:', error);
|
||||
return { success: false, error: '서버 오류가 발생했습니다.', errorType: 'GENERAL' };
|
||||
return {
|
||||
success: false,
|
||||
error: '카테고리 삭제에 실패했습니다.',
|
||||
errorType: 'GENERAL',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 카테고리 순서 변경 =====
|
||||
/**
|
||||
* 카테고리 순서 변경
|
||||
* POST /api/v1/categories/reorder
|
||||
*/
|
||||
export async function reorderCategories(
|
||||
items: { id: string; sort_order: number }[]
|
||||
): Promise<{
|
||||
@@ -129,17 +170,15 @@ export async function reorderCategories(
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
// 순서 업데이트
|
||||
items.forEach(item => {
|
||||
const category = mockCategories.find(c => c.id === item.id);
|
||||
if (category) {
|
||||
category.order = item.sort_order;
|
||||
}
|
||||
await apiClient.post('/categories/reorder', {
|
||||
items: items.map((item) => ({
|
||||
id: Number(item.id),
|
||||
sort_order: item.sort_order,
|
||||
})),
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('[reorderCategories] Error:', error);
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
console.error('카테고리 순서 변경 오류:', error);
|
||||
return { success: false, error: '순서 변경에 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
@@ -9,531 +9,402 @@ import type {
|
||||
ContractFilter,
|
||||
ContractFormData,
|
||||
} from './types';
|
||||
import { apiClient } from '@/lib/api';
|
||||
|
||||
// 목업 데이터
|
||||
const MOCK_CONTRACTS: Contract[] = [
|
||||
{
|
||||
id: '1',
|
||||
contractCode: 'CT-2025-001',
|
||||
partnerId: '1',
|
||||
partnerName: '통신공사',
|
||||
projectName: '강남역 통신시설 구축',
|
||||
contractManagerId: 'hong',
|
||||
contractManagerName: '홍길동',
|
||||
constructionPMId: 'kim',
|
||||
constructionPMName: '김PM',
|
||||
totalLocations: 15,
|
||||
contractAmount: 150000000,
|
||||
contractStartDate: '2025-12-17',
|
||||
contractEndDate: '2026-06-17',
|
||||
status: 'pending',
|
||||
stage: 'estimate_selected',
|
||||
remarks: '',
|
||||
createdAt: '2025-01-01',
|
||||
updatedAt: '2025-01-01',
|
||||
createdBy: 'system',
|
||||
biddingId: '1',
|
||||
biddingCode: 'BID-2025-001',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
contractCode: 'CT-2025-002',
|
||||
partnerId: '2',
|
||||
partnerName: '야사건설',
|
||||
projectName: '판교 IT단지 배선공사',
|
||||
contractManagerId: 'hong',
|
||||
contractManagerName: '홍길동',
|
||||
constructionPMId: 'lee',
|
||||
constructionPMName: '이PM',
|
||||
totalLocations: 28,
|
||||
contractAmount: 280000000,
|
||||
contractStartDate: '2025-11-01',
|
||||
contractEndDate: '2026-03-31',
|
||||
status: 'pending',
|
||||
stage: 'estimate_progress',
|
||||
remarks: '',
|
||||
createdAt: '2025-01-02',
|
||||
updatedAt: '2025-01-02',
|
||||
createdBy: 'system',
|
||||
biddingId: '2',
|
||||
biddingCode: 'BID-2025-002',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
contractCode: 'CT-2025-003',
|
||||
partnerId: '3',
|
||||
partnerName: '여의건설',
|
||||
projectName: '여의도 오피스빌딩 통신설비',
|
||||
contractManagerId: 'kim',
|
||||
contractManagerName: '김철수',
|
||||
constructionPMId: 'park',
|
||||
constructionPMName: '박PM',
|
||||
totalLocations: 42,
|
||||
contractAmount: 420000000,
|
||||
contractStartDate: '2025-10-15',
|
||||
contractEndDate: '2026-04-15',
|
||||
status: 'pending',
|
||||
stage: 'delivery',
|
||||
remarks: '',
|
||||
createdAt: '2025-01-03',
|
||||
updatedAt: '2025-01-03',
|
||||
createdBy: 'system',
|
||||
biddingId: '3',
|
||||
biddingCode: 'BID-2025-003',
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
contractCode: 'CT-2025-004',
|
||||
partnerId: '1',
|
||||
partnerName: '통신공사',
|
||||
projectName: '송파 데이터센터 증설',
|
||||
contractManagerId: 'hong',
|
||||
contractManagerName: '홍길동',
|
||||
constructionPMId: 'kim',
|
||||
constructionPMName: '김PM',
|
||||
totalLocations: 58,
|
||||
contractAmount: 580000000,
|
||||
contractStartDate: '2025-09-01',
|
||||
contractEndDate: '2026-02-28',
|
||||
status: 'completed',
|
||||
stage: 'inspection',
|
||||
remarks: '',
|
||||
createdAt: '2025-01-04',
|
||||
updatedAt: '2025-01-04',
|
||||
createdBy: 'system',
|
||||
biddingId: '4',
|
||||
biddingCode: 'BID-2025-004',
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
contractCode: 'CT-2025-005',
|
||||
partnerId: '2',
|
||||
partnerName: '야사건설',
|
||||
projectName: '분당 스마트빌딩 LAN공사',
|
||||
contractManagerId: 'lee',
|
||||
contractManagerName: '이영희',
|
||||
constructionPMId: 'lee',
|
||||
constructionPMName: '이PM',
|
||||
totalLocations: 12,
|
||||
contractAmount: 95000000,
|
||||
contractStartDate: '2025-12-01',
|
||||
contractEndDate: '2026-01-31',
|
||||
status: 'pending',
|
||||
stage: 'installation',
|
||||
remarks: '',
|
||||
createdAt: '2025-01-05',
|
||||
updatedAt: '2025-01-05',
|
||||
createdBy: 'system',
|
||||
biddingId: '5',
|
||||
biddingCode: 'BID-2025-005',
|
||||
},
|
||||
{
|
||||
id: '6',
|
||||
contractCode: 'CT-2025-006',
|
||||
partnerId: '3',
|
||||
partnerName: '여의건설',
|
||||
projectName: '마포 복합시설 CCTV설치',
|
||||
contractManagerId: 'hong',
|
||||
contractManagerName: '홍길동',
|
||||
constructionPMId: 'park',
|
||||
constructionPMName: '박PM',
|
||||
totalLocations: 8,
|
||||
contractAmount: 75000000,
|
||||
contractStartDate: '2025-08-01',
|
||||
contractEndDate: '2025-10-31',
|
||||
status: 'completed',
|
||||
stage: 'estimate_selected',
|
||||
remarks: '',
|
||||
createdAt: '2025-01-06',
|
||||
updatedAt: '2025-01-06',
|
||||
createdBy: 'system',
|
||||
biddingId: '6',
|
||||
biddingCode: 'BID-2025-006',
|
||||
},
|
||||
{
|
||||
id: '7',
|
||||
contractCode: 'CT-2025-007',
|
||||
partnerId: '1',
|
||||
partnerName: '통신공사',
|
||||
projectName: '용산 아파트 인터폰교체',
|
||||
contractManagerId: 'kim',
|
||||
contractManagerName: '김철수',
|
||||
constructionPMId: 'kim',
|
||||
constructionPMName: '김PM',
|
||||
totalLocations: 120,
|
||||
contractAmount: 45000000,
|
||||
contractStartDate: '2025-07-15',
|
||||
contractEndDate: '2025-09-15',
|
||||
status: 'completed',
|
||||
stage: 'estimate_progress',
|
||||
remarks: '',
|
||||
createdAt: '2025-01-07',
|
||||
updatedAt: '2025-01-07',
|
||||
createdBy: 'system',
|
||||
biddingId: '7',
|
||||
biddingCode: 'BID-2025-007',
|
||||
},
|
||||
{
|
||||
id: '8',
|
||||
contractCode: 'CT-2025-008',
|
||||
partnerId: '2',
|
||||
partnerName: '야사건설',
|
||||
projectName: '성수동 공장 방범설비',
|
||||
contractManagerId: 'lee',
|
||||
contractManagerName: '이영희',
|
||||
constructionPMId: 'lee',
|
||||
constructionPMName: '이PM',
|
||||
totalLocations: 24,
|
||||
contractAmount: 120000000,
|
||||
contractStartDate: '2025-11-15',
|
||||
contractEndDate: '2026-02-15',
|
||||
status: 'pending',
|
||||
stage: 'other',
|
||||
remarks: '',
|
||||
createdAt: '2025-01-08',
|
||||
updatedAt: '2025-01-08',
|
||||
createdBy: 'system',
|
||||
biddingId: '8',
|
||||
biddingCode: 'BID-2025-008',
|
||||
},
|
||||
{
|
||||
id: '9',
|
||||
contractCode: 'CT-2025-009',
|
||||
partnerId: '3',
|
||||
partnerName: '여의건설',
|
||||
projectName: '강서 물류센터 네트워크',
|
||||
contractManagerId: 'hong',
|
||||
contractManagerName: '홍길동',
|
||||
constructionPMId: 'park',
|
||||
constructionPMName: '박PM',
|
||||
totalLocations: 35,
|
||||
contractAmount: 320000000,
|
||||
contractStartDate: '2025-06-01',
|
||||
contractEndDate: '2025-11-30',
|
||||
status: 'completed',
|
||||
stage: 'inspection',
|
||||
remarks: '',
|
||||
createdAt: '2025-01-09',
|
||||
updatedAt: '2025-01-09',
|
||||
createdBy: 'system',
|
||||
biddingId: '9',
|
||||
biddingCode: 'BID-2025-009',
|
||||
},
|
||||
];
|
||||
/**
|
||||
* 주일 기업 - 계약관리 Server Actions
|
||||
* 표준화된 apiClient 사용 버전
|
||||
*/
|
||||
|
||||
// 계약 목록 조회
|
||||
// ========================================
|
||||
// API 응답 타입
|
||||
// ========================================
|
||||
|
||||
interface ApiContract {
|
||||
id: number;
|
||||
contract_code: string;
|
||||
partner_id: number | null;
|
||||
partner_name: string | null;
|
||||
project_name: string;
|
||||
contract_manager_id: number | null;
|
||||
contract_manager_name: string | null;
|
||||
construction_pm_id: number | null;
|
||||
construction_pm_name: string | null;
|
||||
total_locations: number;
|
||||
contract_amount: number;
|
||||
contract_start_date: string | null;
|
||||
contract_end_date: string | null;
|
||||
status: 'pending' | 'completed';
|
||||
stage: string;
|
||||
remarks: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
created_by: string | null;
|
||||
bidding_id: number | null;
|
||||
bidding_code: string | null;
|
||||
contract_file?: ApiContractFile | null;
|
||||
attachments?: ApiAttachment[];
|
||||
}
|
||||
|
||||
interface ApiContractFile {
|
||||
id: number;
|
||||
file_name: string;
|
||||
file_url: string;
|
||||
uploaded_at: string;
|
||||
}
|
||||
|
||||
interface ApiAttachment {
|
||||
id: number;
|
||||
file_name: string;
|
||||
file_size: number;
|
||||
file_url: string;
|
||||
uploaded_at: string;
|
||||
}
|
||||
|
||||
interface ApiContractStats {
|
||||
total_count: number;
|
||||
pending_count: number;
|
||||
completed_count: number;
|
||||
}
|
||||
|
||||
interface ApiContractStageCount {
|
||||
estimate_selected: number;
|
||||
estimate_progress: number;
|
||||
delivery: number;
|
||||
installation: number;
|
||||
inspection: number;
|
||||
other: number;
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 타입 변환 함수
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* API 응답 → Contract 타입 변환
|
||||
*/
|
||||
function transformContract(apiData: ApiContract): Contract {
|
||||
return {
|
||||
id: String(apiData.id),
|
||||
contractCode: apiData.contract_code || '',
|
||||
partnerId: apiData.partner_id ? String(apiData.partner_id) : '',
|
||||
partnerName: apiData.partner_name || '',
|
||||
projectName: apiData.project_name || '',
|
||||
contractManagerId: apiData.contract_manager_id ? String(apiData.contract_manager_id) : '',
|
||||
contractManagerName: apiData.contract_manager_name || '',
|
||||
constructionPMId: apiData.construction_pm_id ? String(apiData.construction_pm_id) : '',
|
||||
constructionPMName: apiData.construction_pm_name || '',
|
||||
totalLocations: apiData.total_locations || 0,
|
||||
contractAmount: apiData.contract_amount || 0,
|
||||
contractStartDate: apiData.contract_start_date || null,
|
||||
contractEndDate: apiData.contract_end_date || null,
|
||||
status: apiData.status || 'pending',
|
||||
stage: (apiData.stage as Contract['stage']) || 'estimate_selected',
|
||||
remarks: apiData.remarks || '',
|
||||
createdAt: apiData.created_at || '',
|
||||
updatedAt: apiData.updated_at || '',
|
||||
createdBy: apiData.created_by || '',
|
||||
biddingId: apiData.bidding_id ? String(apiData.bidding_id) : '',
|
||||
biddingCode: apiData.bidding_code || '',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* API 응답 → ContractDetail 타입 변환
|
||||
*/
|
||||
function transformContractDetail(apiData: ApiContract): ContractDetail {
|
||||
const contract = transformContract(apiData);
|
||||
|
||||
return {
|
||||
...contract,
|
||||
contractFile: apiData.contract_file
|
||||
? {
|
||||
id: String(apiData.contract_file.id),
|
||||
fileName: apiData.contract_file.file_name || '',
|
||||
fileUrl: apiData.contract_file.file_url || '',
|
||||
uploadedAt: apiData.contract_file.uploaded_at || '',
|
||||
}
|
||||
: null,
|
||||
attachments: (apiData.attachments || []).map((att) => ({
|
||||
id: String(att.id),
|
||||
fileName: att.file_name || '',
|
||||
fileSize: att.file_size || 0,
|
||||
fileUrl: att.file_url || '',
|
||||
uploadedAt: att.uploaded_at || '',
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* ContractFormData → API 요청 데이터 변환
|
||||
*/
|
||||
function transformToApiRequest(data: Partial<ContractFormData>): Record<string, unknown> {
|
||||
const apiData: Record<string, unknown> = {};
|
||||
|
||||
if (data.contractCode !== undefined) apiData.contract_code = data.contractCode;
|
||||
if (data.projectName !== undefined) apiData.project_name = data.projectName;
|
||||
if (data.partnerId !== undefined) apiData.partner_id = data.partnerId || null;
|
||||
if (data.partnerName !== undefined) apiData.partner_name = data.partnerName || null;
|
||||
if (data.contractManagerId !== undefined) apiData.contract_manager_id = data.contractManagerId || null;
|
||||
if (data.contractManagerName !== undefined) apiData.contract_manager_name = data.contractManagerName || null;
|
||||
if (data.totalLocations !== undefined) apiData.total_locations = data.totalLocations;
|
||||
if (data.contractAmount !== undefined) apiData.contract_amount = data.contractAmount;
|
||||
if (data.contractStartDate !== undefined) apiData.contract_start_date = data.contractStartDate || null;
|
||||
if (data.contractEndDate !== undefined) apiData.contract_end_date = data.contractEndDate || null;
|
||||
if (data.status !== undefined) apiData.status = data.status;
|
||||
if (data.remarks !== undefined) apiData.remarks = data.remarks || null;
|
||||
|
||||
return apiData;
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// API 함수
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* 계약 목록 조회
|
||||
* GET /api/v1/construction/contracts
|
||||
*/
|
||||
export async function getContractList(filter?: ContractFilter): Promise<{
|
||||
success: boolean;
|
||||
data?: ContractListResponse;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
const queryParams: Record<string, string> = {};
|
||||
|
||||
let filteredData = [...MOCK_CONTRACTS];
|
||||
// 검색
|
||||
if (filter?.search) queryParams.search = filter.search;
|
||||
|
||||
// 검색 필터
|
||||
if (filter?.search) {
|
||||
const search = filter.search.toLowerCase();
|
||||
filteredData = filteredData.filter(
|
||||
(item) =>
|
||||
item.contractCode.toLowerCase().includes(search) ||
|
||||
item.partnerName.toLowerCase().includes(search) ||
|
||||
item.projectName.toLowerCase().includes(search)
|
||||
);
|
||||
}
|
||||
|
||||
// 상태 필터
|
||||
if (filter?.status && filter.status !== 'all') {
|
||||
filteredData = filteredData.filter((item) => item.status === filter.status);
|
||||
}
|
||||
|
||||
// 단계 필터
|
||||
if (filter?.stage && filter.stage !== 'all') {
|
||||
filteredData = filteredData.filter((item) => item.stage === filter.stage);
|
||||
}
|
||||
|
||||
// 거래처 필터
|
||||
if (filter?.partnerId && filter.partnerId !== 'all') {
|
||||
filteredData = filteredData.filter((item) => item.partnerId === filter.partnerId);
|
||||
}
|
||||
|
||||
// 계약담당자 필터
|
||||
// 필터
|
||||
if (filter?.status && filter.status !== 'all') queryParams.status = filter.status;
|
||||
if (filter?.stage && filter.stage !== 'all') queryParams.stage = filter.stage;
|
||||
if (filter?.partnerId && filter.partnerId !== 'all') queryParams.partner_id = filter.partnerId;
|
||||
if (filter?.contractManagerId && filter.contractManagerId !== 'all') {
|
||||
filteredData = filteredData.filter((item) => item.contractManagerId === filter.contractManagerId);
|
||||
queryParams.contract_manager_id = filter.contractManagerId;
|
||||
}
|
||||
|
||||
// 공사PM 필터
|
||||
if (filter?.constructionPMId && filter.constructionPMId !== 'all') {
|
||||
filteredData = filteredData.filter((item) => item.constructionPMId === filter.constructionPMId);
|
||||
queryParams.construction_pm_id = filter.constructionPMId;
|
||||
}
|
||||
|
||||
// 날짜 필터
|
||||
if (filter?.startDate) {
|
||||
filteredData = filteredData.filter(
|
||||
(item) => item.contractStartDate && item.contractStartDate >= filter.startDate!
|
||||
);
|
||||
}
|
||||
if (filter?.endDate) {
|
||||
filteredData = filteredData.filter(
|
||||
(item) => item.contractEndDate && item.contractEndDate <= filter.endDate!
|
||||
);
|
||||
}
|
||||
|
||||
// 정렬
|
||||
const sortBy = filter?.sortBy || 'contractDateDesc';
|
||||
switch (sortBy) {
|
||||
case 'contractDateDesc':
|
||||
filteredData.sort((a, b) => {
|
||||
if (!a.contractStartDate) return 1;
|
||||
if (!b.contractStartDate) return -1;
|
||||
return new Date(b.contractStartDate).getTime() - new Date(a.contractStartDate).getTime();
|
||||
});
|
||||
break;
|
||||
case 'contractDateAsc':
|
||||
filteredData.sort((a, b) => {
|
||||
if (!a.contractStartDate) return 1;
|
||||
if (!b.contractStartDate) return -1;
|
||||
return new Date(a.contractStartDate).getTime() - new Date(b.contractStartDate).getTime();
|
||||
});
|
||||
break;
|
||||
case 'partnerNameAsc':
|
||||
filteredData.sort((a, b) => a.partnerName.localeCompare(b.partnerName, 'ko'));
|
||||
break;
|
||||
case 'partnerNameDesc':
|
||||
filteredData.sort((a, b) => b.partnerName.localeCompare(a.partnerName, 'ko'));
|
||||
break;
|
||||
case 'projectNameAsc':
|
||||
filteredData.sort((a, b) => a.projectName.localeCompare(b.projectName, 'ko'));
|
||||
break;
|
||||
case 'projectNameDesc':
|
||||
filteredData.sort((a, b) => b.projectName.localeCompare(a.projectName, 'ko'));
|
||||
break;
|
||||
case 'amountDesc':
|
||||
filteredData.sort((a, b) => b.contractAmount - a.contractAmount);
|
||||
break;
|
||||
case 'amountAsc':
|
||||
filteredData.sort((a, b) => a.contractAmount - b.contractAmount);
|
||||
break;
|
||||
}
|
||||
// 날짜 범위
|
||||
if (filter?.startDate) queryParams.start_date = filter.startDate;
|
||||
if (filter?.endDate) queryParams.end_date = filter.endDate;
|
||||
|
||||
// 페이지네이션
|
||||
const page = filter?.page || 1;
|
||||
const size = filter?.size || 20;
|
||||
const startIndex = (page - 1) * size;
|
||||
const paginatedData = filteredData.slice(startIndex, startIndex + size);
|
||||
if (filter?.page) queryParams.page = String(filter.page);
|
||||
if (filter?.size) queryParams.per_page = String(filter.size);
|
||||
|
||||
// 정렬
|
||||
if (filter?.sortBy) {
|
||||
const sortMap: Record<string, { field: string; dir: string }> = {
|
||||
contractDateDesc: { field: 'created_at', dir: 'desc' },
|
||||
contractDateAsc: { field: 'created_at', dir: 'asc' },
|
||||
partnerNameAsc: { field: 'partner_name', dir: 'asc' },
|
||||
partnerNameDesc: { field: 'partner_name', dir: 'desc' },
|
||||
projectNameAsc: { field: 'project_name', dir: 'asc' },
|
||||
projectNameDesc: { field: 'project_name', dir: 'desc' },
|
||||
amountDesc: { field: 'contract_amount', dir: 'desc' },
|
||||
amountAsc: { field: 'contract_amount', dir: 'asc' },
|
||||
};
|
||||
const sort = sortMap[filter.sortBy];
|
||||
if (sort) {
|
||||
queryParams.sort_by = sort.field;
|
||||
queryParams.sort_dir = sort.dir;
|
||||
}
|
||||
}
|
||||
|
||||
const response = await apiClient.get<{
|
||||
data: ApiContract[];
|
||||
current_page: number;
|
||||
per_page: number;
|
||||
total: number;
|
||||
last_page: number;
|
||||
}>('/construction/contracts', { params: queryParams });
|
||||
|
||||
const items = (response.data || []).map(transformContract);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
items: paginatedData,
|
||||
total: filteredData.length,
|
||||
page,
|
||||
size,
|
||||
totalPages: Math.ceil(filteredData.length / size),
|
||||
items,
|
||||
total: response.total || 0,
|
||||
page: response.current_page || 1,
|
||||
size: response.per_page || 20,
|
||||
totalPages: response.last_page || 1,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('getContractList error:', error);
|
||||
console.error('계약 목록 조회 오류:', error);
|
||||
return { success: false, error: '계약 목록을 불러오는데 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// 계약 통계 조회
|
||||
/**
|
||||
* 계약 통계 조회
|
||||
* GET /api/v1/construction/contracts/stats
|
||||
*/
|
||||
export async function getContractStats(): Promise<{
|
||||
success: boolean;
|
||||
data?: ContractStats;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
const response = await apiClient.get<ApiContractStats>('/construction/contracts/stats');
|
||||
|
||||
const stats: ContractStats = {
|
||||
total: MOCK_CONTRACTS.length,
|
||||
pending: MOCK_CONTRACTS.filter((c) => c.status === 'pending').length,
|
||||
completed: MOCK_CONTRACTS.filter((c) => c.status === 'completed').length,
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
total: response.total_count || 0,
|
||||
pending: response.pending_count || 0,
|
||||
completed: response.completed_count || 0,
|
||||
},
|
||||
};
|
||||
|
||||
return { success: true, data: stats };
|
||||
} catch (error) {
|
||||
console.error('getContractStats error:', error);
|
||||
console.error('계약 통계 조회 오류:', error);
|
||||
return { success: false, error: '통계를 불러오는데 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// 단계별 건수 조회
|
||||
/**
|
||||
* 단계별 건수 조회
|
||||
* GET /api/v1/construction/contracts/stage-counts
|
||||
*/
|
||||
export async function getContractStageCounts(): Promise<{
|
||||
success: boolean;
|
||||
data?: ContractStageCount;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
const response = await apiClient.get<ApiContractStageCount>('/construction/contracts/stage-counts');
|
||||
|
||||
const counts: ContractStageCount = {
|
||||
estimateSelected: MOCK_CONTRACTS.filter((c) => c.stage === 'estimate_selected').length,
|
||||
estimateProgress: MOCK_CONTRACTS.filter((c) => c.stage === 'estimate_progress').length,
|
||||
delivery: MOCK_CONTRACTS.filter((c) => c.stage === 'delivery').length,
|
||||
installation: MOCK_CONTRACTS.filter((c) => c.stage === 'installation').length,
|
||||
inspection: MOCK_CONTRACTS.filter((c) => c.stage === 'inspection').length,
|
||||
other: MOCK_CONTRACTS.filter((c) => c.stage === 'other').length,
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
estimateSelected: response.estimate_selected || 0,
|
||||
estimateProgress: response.estimate_progress || 0,
|
||||
delivery: response.delivery || 0,
|
||||
installation: response.installation || 0,
|
||||
inspection: response.inspection || 0,
|
||||
other: response.other || 0,
|
||||
},
|
||||
};
|
||||
|
||||
return { success: true, data: counts };
|
||||
} catch (error) {
|
||||
console.error('getContractStageCounts error:', error);
|
||||
console.error('단계별 건수 조회 오류:', error);
|
||||
return { success: false, error: '단계별 건수를 불러오는데 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// 계약 단건 조회
|
||||
/**
|
||||
* 계약 단건 조회
|
||||
* GET /api/v1/construction/contracts/{id}
|
||||
*/
|
||||
export async function getContract(id: string): Promise<{
|
||||
success: boolean;
|
||||
data?: Contract;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
|
||||
const contract = MOCK_CONTRACTS.find((c) => c.id === id);
|
||||
if (!contract) {
|
||||
return { success: false, error: '계약 정보를 찾을 수 없습니다.' };
|
||||
}
|
||||
|
||||
return { success: true, data: contract };
|
||||
const response = await apiClient.get<ApiContract>(`/construction/contracts/${id}`);
|
||||
return { success: true, data: transformContract(response) };
|
||||
} catch (error) {
|
||||
console.error('getContract error:', error);
|
||||
return { success: false, error: '계약 정보를 불러오는데 실패했습니다.' };
|
||||
console.error('계약 조회 오류:', error);
|
||||
return { success: false, error: '계약 정보를 찾을 수 없습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// 계약 삭제
|
||||
export async function deleteContract(id: string): Promise<{
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
|
||||
const index = MOCK_CONTRACTS.findIndex((c) => c.id === id);
|
||||
if (index === -1) {
|
||||
return { success: false, error: '계약 정보를 찾을 수 없습니다.' };
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('deleteContract error:', error);
|
||||
return { success: false, error: '계약 삭제에 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// 계약 일괄 삭제
|
||||
export async function deleteContracts(ids: string[]): Promise<{
|
||||
success: boolean;
|
||||
deletedCount?: number;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
|
||||
return { success: true, deletedCount: ids.length };
|
||||
} catch (error) {
|
||||
console.error('deleteContracts error:', error);
|
||||
return { success: false, error: '일괄 삭제에 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// 계약 상세 조회 (첨부파일 포함)
|
||||
/**
|
||||
* 계약 상세 조회 (첨부파일 포함)
|
||||
* GET /api/v1/construction/contracts/{id}
|
||||
*/
|
||||
export async function getContractDetail(id: string): Promise<{
|
||||
success: boolean;
|
||||
data?: ContractDetail;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
|
||||
const contract = MOCK_CONTRACTS.find((c) => c.id === id);
|
||||
if (!contract) {
|
||||
return { success: false, error: '계약 정보를 찾을 수 없습니다.' };
|
||||
}
|
||||
|
||||
// ContractDetail로 변환 (첨부파일 목데이터 포함)
|
||||
const contractDetail: ContractDetail = {
|
||||
...contract,
|
||||
// 계약서 파일 목업 데이터
|
||||
contractFile: {
|
||||
id: '100',
|
||||
fileName: '계약서_CT-2025-001.pdf',
|
||||
fileUrl: '/files/contract.pdf',
|
||||
uploadedAt: contract.createdAt,
|
||||
},
|
||||
attachments: [
|
||||
{
|
||||
id: 'att-1',
|
||||
fileName: '견적서.pdf',
|
||||
fileSize: 1024000,
|
||||
fileUrl: '/files/estimate.pdf',
|
||||
uploadedAt: contract.createdAt,
|
||||
},
|
||||
{
|
||||
id: 'att-2',
|
||||
fileName: '시방서.pdf',
|
||||
fileSize: 2048000,
|
||||
fileUrl: '/files/spec.pdf',
|
||||
uploadedAt: contract.createdAt,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
return { success: true, data: contractDetail };
|
||||
const response = await apiClient.get<ApiContract>(`/construction/contracts/${id}`);
|
||||
return { success: true, data: transformContractDetail(response) };
|
||||
} catch (error) {
|
||||
console.error('getContractDetail error:', error);
|
||||
console.error('계약 상세 조회 오류:', error);
|
||||
return { success: false, error: '계약 상세 정보를 불러오는데 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// 계약 수정
|
||||
export async function updateContract(
|
||||
id: string,
|
||||
_data: Partial<ContractFormData>
|
||||
): Promise<{
|
||||
/**
|
||||
* 계약 등록
|
||||
* POST /api/v1/construction/contracts
|
||||
*/
|
||||
export async function createContract(data: ContractFormData): Promise<{
|
||||
success: boolean;
|
||||
data?: Contract;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
|
||||
const index = MOCK_CONTRACTS.findIndex((c) => c.id === id);
|
||||
if (index === -1) {
|
||||
return { success: false, error: '계약 정보를 찾을 수 없습니다.' };
|
||||
}
|
||||
|
||||
// TODO: 실제 API 연동 시 데이터 업데이트 로직
|
||||
return { success: true };
|
||||
const apiData = transformToApiRequest(data);
|
||||
const response = await apiClient.post<ApiContract>('/construction/contracts', apiData);
|
||||
return { success: true, data: transformContract(response) };
|
||||
} catch (error) {
|
||||
console.error('updateContract error:', error);
|
||||
console.error('계약 등록 오류:', error);
|
||||
return { success: false, error: '계약 등록에 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 계약 수정
|
||||
* PUT /api/v1/construction/contracts/{id}
|
||||
*/
|
||||
export async function updateContract(
|
||||
id: string,
|
||||
data: Partial<ContractFormData>
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
data?: Contract;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
const apiData = transformToApiRequest(data);
|
||||
const response = await apiClient.put<ApiContract>(`/construction/contracts/${id}`, apiData);
|
||||
return { success: true, data: transformContract(response) };
|
||||
} catch (error) {
|
||||
console.error('계약 수정 오류:', error);
|
||||
return { success: false, error: '계약 수정에 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// 계약 생성 (변경 계약서 생성 포함)
|
||||
export async function createContract(
|
||||
_data: ContractFormData
|
||||
): Promise<{
|
||||
/**
|
||||
* 계약 삭제
|
||||
* DELETE /api/v1/construction/contracts/{id}
|
||||
*/
|
||||
export async function deleteContract(id: string): Promise<{
|
||||
success: boolean;
|
||||
data?: { id: string };
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
|
||||
// TODO: 실제 API 연동 시 데이터 생성 로직
|
||||
// 새 계약 ID 생성 (목업)
|
||||
const newId = String(MOCK_CONTRACTS.length + 1);
|
||||
|
||||
return { success: true, data: { id: newId } };
|
||||
await apiClient.delete(`/construction/contracts/${id}`);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('createContract error:', error);
|
||||
return { success: false, error: '계약 생성에 실패했습니다.' };
|
||||
console.error('계약 삭제 오류:', error);
|
||||
return { success: false, error: '계약 삭제에 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 계약 일괄 삭제
|
||||
* DELETE /api/v1/construction/contracts/bulk
|
||||
*/
|
||||
export async function deleteContracts(ids: string[]): Promise<{
|
||||
success: boolean;
|
||||
deletedCount?: number;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
await apiClient.delete('/construction/contracts/bulk', {
|
||||
data: { ids: ids.map((id) => Number(id)) },
|
||||
});
|
||||
return { success: true, deletedCount: ids.length };
|
||||
} catch (error) {
|
||||
console.error('계약 일괄 삭제 오류:', error);
|
||||
return { success: false, error: '일괄 삭제에 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,7 +97,7 @@ export default function EstimateListClient({ initialData = [], initialStats }: E
|
||||
try {
|
||||
const [listResult, statsResult] = await Promise.all([
|
||||
getEstimateList({
|
||||
size: 1000,
|
||||
size: 100, // API 최대값 100
|
||||
startDate: startDate || undefined,
|
||||
endDate: endDate || undefined,
|
||||
}),
|
||||
|
||||
@@ -1,279 +1,642 @@
|
||||
'use server';
|
||||
|
||||
import type { Estimate, EstimateStats, EstimateFilter, EstimateListResponse } from './types';
|
||||
import type {
|
||||
Estimate,
|
||||
EstimateDetail,
|
||||
EstimateStats,
|
||||
EstimateFilter,
|
||||
EstimateListResponse,
|
||||
EstimateDetailFormData,
|
||||
EstimateSummaryItem,
|
||||
ExpenseItem,
|
||||
PriceAdjustmentItem,
|
||||
EstimateDetailItem,
|
||||
SiteBriefingInfo,
|
||||
BidInfo,
|
||||
} from './types';
|
||||
import { apiClient } from '@/lib/api';
|
||||
|
||||
/**
|
||||
* 주일 기업 - 견적관리 Server Actions
|
||||
* TODO: 실제 API 연동 시 구현
|
||||
* 건설 프로젝트 - 견적관리 Server Actions
|
||||
* quotes API 사용 (quote_type=construction 필터)
|
||||
*/
|
||||
|
||||
// 목업 데이터
|
||||
const mockEstimates: Estimate[] = [
|
||||
{
|
||||
id: '1',
|
||||
estimateCode: '123123',
|
||||
partnerId: '1',
|
||||
partnerName: '회사명',
|
||||
projectName: '삼성 엘에이 사옥',
|
||||
estimatorId: 'hong',
|
||||
estimatorName: '홍길동',
|
||||
itemCount: 8,
|
||||
estimateAmount: 100000000,
|
||||
completedDate: null,
|
||||
bidDate: '2025-12-15',
|
||||
status: 'pending',
|
||||
createdAt: '2025-01-01',
|
||||
updatedAt: '2025-01-01',
|
||||
createdBy: '홍길동',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
estimateCode: '123123',
|
||||
partnerId: '2',
|
||||
partnerName: '야사 대림아파트',
|
||||
projectName: '마포 물류센터 증축',
|
||||
estimatorId: 'hong',
|
||||
estimatorName: '홍길동',
|
||||
itemCount: 8,
|
||||
estimateAmount: 100000000,
|
||||
completedDate: null,
|
||||
bidDate: '2025-12-15',
|
||||
status: 'pending',
|
||||
createdAt: '2025-01-02',
|
||||
updatedAt: '2025-01-02',
|
||||
createdBy: '홍길동',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
estimateCode: '123123',
|
||||
partnerId: '3',
|
||||
partnerName: '여의 현장아파트',
|
||||
projectName: '여의도 상업시설 신축',
|
||||
estimatorId: 'hong',
|
||||
estimatorName: '홍길동',
|
||||
itemCount: 21,
|
||||
estimateAmount: 50000000,
|
||||
completedDate: null,
|
||||
bidDate: '2025-12-15',
|
||||
status: 'pending',
|
||||
createdAt: '2025-01-03',
|
||||
updatedAt: '2025-01-03',
|
||||
createdBy: '홍길동',
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
estimateCode: '123123',
|
||||
partnerId: '1',
|
||||
partnerName: '회사명',
|
||||
projectName: '강남 오피스텔 신축',
|
||||
estimatorId: 'hong',
|
||||
estimatorName: '홍길동',
|
||||
itemCount: 0,
|
||||
estimateAmount: 10000000,
|
||||
completedDate: '2025-12-10',
|
||||
bidDate: '2025-12-15',
|
||||
status: 'completed',
|
||||
createdAt: '2025-01-04',
|
||||
updatedAt: '2025-01-04',
|
||||
createdBy: '홍길동',
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
estimateCode: '123123',
|
||||
partnerId: '2',
|
||||
partnerName: '야사 대림아파트',
|
||||
projectName: '서초 아파트 리모델링',
|
||||
estimatorId: 'hong',
|
||||
estimatorName: '홍길동',
|
||||
itemCount: 0,
|
||||
estimateAmount: 10000000,
|
||||
completedDate: '2025-12-11',
|
||||
bidDate: '2025-12-15',
|
||||
status: 'completed',
|
||||
createdAt: '2025-01-05',
|
||||
updatedAt: '2025-01-05',
|
||||
createdBy: '홍길동',
|
||||
},
|
||||
{
|
||||
id: '6',
|
||||
estimateCode: '123123',
|
||||
partnerId: '3',
|
||||
partnerName: '회사명',
|
||||
projectName: '송파 주상복합 공사',
|
||||
estimatorId: 'hong',
|
||||
estimatorName: '홍길동',
|
||||
itemCount: 0,
|
||||
estimateAmount: 10000000,
|
||||
completedDate: '2025-12-12',
|
||||
bidDate: '2025-12-15',
|
||||
status: 'completed',
|
||||
createdAt: '2025-01-06',
|
||||
updatedAt: '2025-01-06',
|
||||
createdBy: '홍길동',
|
||||
},
|
||||
{
|
||||
id: '7',
|
||||
estimateCode: '123125',
|
||||
partnerId: '1',
|
||||
partnerName: '회사명',
|
||||
projectName: '판교 테크노밸리 빌딩',
|
||||
estimatorId: 'kim',
|
||||
estimatorName: '김철수',
|
||||
itemCount: 15,
|
||||
estimateAmount: 200000000,
|
||||
completedDate: null,
|
||||
bidDate: '2025-12-20',
|
||||
status: 'pending',
|
||||
createdAt: '2025-01-07',
|
||||
updatedAt: '2025-01-07',
|
||||
createdBy: '김철수',
|
||||
},
|
||||
];
|
||||
// ========================================
|
||||
// API 응답 타입 (Quotes API)
|
||||
// ========================================
|
||||
|
||||
// 견적 목록 조회
|
||||
export async function getEstimateList(
|
||||
filter?: EstimateFilter
|
||||
): Promise<{ success: boolean; data?: EstimateListResponse; error?: string }> {
|
||||
interface ApiQuote {
|
||||
id: number;
|
||||
quote_type: string;
|
||||
quote_number: string;
|
||||
registration_date: string;
|
||||
client_id: number | null;
|
||||
client_name: string | null;
|
||||
site_id: number | null;
|
||||
site_name: string | null;
|
||||
site_briefing_id: number | null;
|
||||
product_category: string | null;
|
||||
product_name: string | null;
|
||||
total_amount: number | string;
|
||||
status: string;
|
||||
author: string | null;
|
||||
manager: string | null;
|
||||
remarks: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
created_by: number | null;
|
||||
// 연관 데이터
|
||||
items?: ApiQuoteItem[];
|
||||
site_briefing?: ApiSiteBriefing;
|
||||
// 옵션 데이터 (JSON)
|
||||
options?: ApiQuoteOptions;
|
||||
}
|
||||
|
||||
interface ApiQuoteOptions {
|
||||
summary_items?: ApiSummaryItem[];
|
||||
expense_items?: ApiExpenseItem[];
|
||||
price_adjustments?: ApiPriceAdjustment[];
|
||||
}
|
||||
|
||||
interface ApiSummaryItem {
|
||||
id: string;
|
||||
name: string;
|
||||
quantity: number;
|
||||
unit: string;
|
||||
material_cost: number;
|
||||
labor_cost: number;
|
||||
total_cost: number;
|
||||
remarks?: string;
|
||||
}
|
||||
|
||||
interface ApiExpenseItem {
|
||||
id: string;
|
||||
name: string;
|
||||
amount: number;
|
||||
}
|
||||
|
||||
interface ApiPriceAdjustment {
|
||||
id: string;
|
||||
category: string;
|
||||
unit_price: number;
|
||||
coating: number;
|
||||
batting: number;
|
||||
box_reinforce: number;
|
||||
painting: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
interface ApiQuoteItem {
|
||||
id: number;
|
||||
item_code: string;
|
||||
item_name: string;
|
||||
specification: string | null;
|
||||
unit: string;
|
||||
base_quantity: number;
|
||||
calculated_quantity: number;
|
||||
unit_price: number;
|
||||
total_price: number;
|
||||
formula: string | null;
|
||||
note: string | null;
|
||||
sort_order: number;
|
||||
}
|
||||
|
||||
interface ApiSiteBriefing {
|
||||
id: number;
|
||||
briefing_code: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
briefing_date: string;
|
||||
briefing_time: string | null;
|
||||
location: string | null;
|
||||
address: string | null;
|
||||
status: string;
|
||||
bid_status: string;
|
||||
bid_date: string | null;
|
||||
attendance_status: string;
|
||||
attendees: Array<{ name: string; department?: string }> | null;
|
||||
attendee_count: number;
|
||||
site_count: number;
|
||||
construction_start_date: string | null;
|
||||
construction_end_date: string | null;
|
||||
vat_type: string;
|
||||
partner?: {
|
||||
id: number;
|
||||
name: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface ApiQuoteStats {
|
||||
total_count: number;
|
||||
pending_count: number;
|
||||
completed_count: number;
|
||||
}
|
||||
|
||||
// Legacy API types (for backward compatibility)
|
||||
interface ApiSiteBriefingInfo {
|
||||
briefing_code: string;
|
||||
partner_name: string;
|
||||
company_name: string;
|
||||
briefing_date: string;
|
||||
attendee: string;
|
||||
}
|
||||
|
||||
interface ApiBidInfo {
|
||||
project_name: string;
|
||||
bid_date: string;
|
||||
site_count: number;
|
||||
construction_period: string;
|
||||
construction_start_date: string;
|
||||
construction_end_date: string;
|
||||
vat_type: string;
|
||||
work_report: string;
|
||||
documents: ApiBidDocument[];
|
||||
}
|
||||
|
||||
interface ApiBidDocument {
|
||||
id: number;
|
||||
file_name: string;
|
||||
file_url: string;
|
||||
file_size: number;
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 타입 변환 함수
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* API 응답 (Quote) → Estimate 타입 변환
|
||||
* 기존 프론트엔드 타입과 호환성 유지
|
||||
*/
|
||||
function transformQuoteToEstimate(apiData: ApiQuote): Estimate {
|
||||
return {
|
||||
id: String(apiData.id),
|
||||
estimateCode: apiData.quote_number || '',
|
||||
partnerId: apiData.client_id ? String(apiData.client_id) : '',
|
||||
partnerName: apiData.client_name || '',
|
||||
projectName: apiData.site_name || '',
|
||||
estimatorId: apiData.created_by ? String(apiData.created_by) : '',
|
||||
estimatorName: apiData.author || '',
|
||||
itemCount: apiData.items?.length || 0,
|
||||
estimateAmount: Number(apiData.total_amount) || 0,
|
||||
completedDate: null,
|
||||
bidDate: apiData.registration_date || null,
|
||||
status: mapQuoteStatusToEstimateStatus(apiData.status),
|
||||
createdAt: apiData.created_at || '',
|
||||
updatedAt: apiData.updated_at || '',
|
||||
createdBy: apiData.created_by ? String(apiData.created_by) : '',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Quote 상태 → Estimate 상태 매핑
|
||||
*/
|
||||
function mapQuoteStatusToEstimateStatus(
|
||||
quoteStatus: string
|
||||
): 'pending' | 'approval_waiting' | 'completed' | 'rejected' | 'hold' {
|
||||
const statusMap: Record<string, 'pending' | 'approval_waiting' | 'completed' | 'rejected' | 'hold'> = {
|
||||
pending: 'pending',
|
||||
draft: 'pending',
|
||||
sent: 'approval_waiting',
|
||||
approved: 'completed',
|
||||
rejected: 'rejected',
|
||||
finalized: 'completed',
|
||||
converted: 'completed',
|
||||
};
|
||||
return statusMap[quoteStatus] || 'pending';
|
||||
}
|
||||
|
||||
/**
|
||||
* API 응답 → EstimateDetail 타입 변환
|
||||
*/
|
||||
function transformQuoteToEstimateDetail(apiData: ApiQuote): EstimateDetail {
|
||||
const base = transformQuoteToEstimate(apiData);
|
||||
const sb = apiData.site_briefing;
|
||||
|
||||
// 참석자 정보 변환
|
||||
const attendeeNames = sb?.attendees
|
||||
? sb.attendees.map((a) => a.name).join(', ')
|
||||
: '';
|
||||
|
||||
// 공사기간 문자열 생성
|
||||
const constructionPeriod =
|
||||
sb?.construction_start_date && sb?.construction_end_date
|
||||
? `${sb.construction_start_date} ~ ${sb.construction_end_date}`
|
||||
: '';
|
||||
|
||||
const siteBriefing: SiteBriefingInfo = sb
|
||||
? {
|
||||
briefingCode: sb.briefing_code || '',
|
||||
partnerName: sb.partner?.name || '',
|
||||
companyName: sb.partner?.name || '',
|
||||
briefingDate: sb.briefing_date || '',
|
||||
attendee: attendeeNames,
|
||||
}
|
||||
: { briefingCode: '', partnerName: '', companyName: '', briefingDate: '', attendee: '' };
|
||||
|
||||
const bidInfo: BidInfo = {
|
||||
projectName: sb?.title || apiData.site_name || '',
|
||||
bidDate: sb?.bid_date || apiData.registration_date || '',
|
||||
siteCount: sb?.site_count || 0,
|
||||
constructionPeriod,
|
||||
constructionStartDate: sb?.construction_start_date || '',
|
||||
constructionEndDate: sb?.construction_end_date || '',
|
||||
vatType: (sb?.vat_type as 'excluded' | 'included') || 'excluded',
|
||||
workReport: sb?.description || '',
|
||||
documents: [],
|
||||
};
|
||||
|
||||
// options에서 데이터 변환
|
||||
const opts = apiData.options;
|
||||
|
||||
const summaryItems: EstimateSummaryItem[] = (opts?.summary_items || []).map((item) => ({
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
quantity: item.quantity,
|
||||
unit: item.unit,
|
||||
materialCost: item.material_cost,
|
||||
laborCost: item.labor_cost,
|
||||
totalCost: item.total_cost,
|
||||
remarks: item.remarks || '',
|
||||
}));
|
||||
|
||||
const expenseItems: ExpenseItem[] = (opts?.expense_items || []).map((item) => ({
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
amount: item.amount,
|
||||
}));
|
||||
|
||||
const priceAdjustments: PriceAdjustmentItem[] = (opts?.price_adjustments || []).map((item) => ({
|
||||
id: item.id,
|
||||
category: item.category,
|
||||
unitPrice: item.unit_price,
|
||||
coating: item.coating,
|
||||
batting: item.batting,
|
||||
boxReinforce: item.box_reinforce,
|
||||
painting: item.painting,
|
||||
total: item.total,
|
||||
}));
|
||||
|
||||
const detailItems: EstimateDetailItem[] = (apiData.items || []).map((item, index) => ({
|
||||
id: String(item.id),
|
||||
no: index + 1,
|
||||
name: item.item_name || '',
|
||||
material: item.specification || '',
|
||||
width: 0,
|
||||
height: 0,
|
||||
quantity: item.calculated_quantity || 0,
|
||||
box: 0,
|
||||
assembly: 0,
|
||||
coating: 0,
|
||||
batting: 0,
|
||||
mounting: 0,
|
||||
fitting: 0,
|
||||
controller: 0,
|
||||
widthConstruction: 0,
|
||||
heightConstruction: 0,
|
||||
materialCost: 0,
|
||||
laborCost: 0,
|
||||
quantityPrice: item.unit_price || 0,
|
||||
expenseQuantity: 0,
|
||||
expenseTotal: 0,
|
||||
totalCost: item.total_price || 0,
|
||||
otherCost: 0,
|
||||
marginCost: 0,
|
||||
totalPrice: item.total_price || 0,
|
||||
unitPrice: item.unit_price || 0,
|
||||
expense: 0,
|
||||
marginRate: 0,
|
||||
unitQuantity: item.base_quantity || 0,
|
||||
expenseResult: 0,
|
||||
marginActual: 0,
|
||||
}));
|
||||
|
||||
return {
|
||||
...base,
|
||||
siteBriefing,
|
||||
bidInfo,
|
||||
summaryItems,
|
||||
expenseItems,
|
||||
priceAdjustments,
|
||||
detailItems,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* EstimateDetailFormData → API 요청 데이터 변환
|
||||
*/
|
||||
function transformToApiRequest(data: Partial<EstimateDetailFormData>): Record<string, unknown> {
|
||||
const apiData: Record<string, unknown> = {};
|
||||
|
||||
if (data.estimateCode !== undefined) apiData.quote_number = data.estimateCode;
|
||||
if (data.estimatorId !== undefined) apiData.created_by = data.estimatorId || null;
|
||||
if (data.estimatorName !== undefined) apiData.author = data.estimatorName || null;
|
||||
if (data.estimateAmount !== undefined) apiData.total_amount = data.estimateAmount;
|
||||
if (data.status !== undefined) {
|
||||
// Estimate 상태 → Quote 상태 역매핑
|
||||
const reverseStatusMap: Record<string, string> = {
|
||||
pending: 'pending',
|
||||
approval_waiting: 'sent',
|
||||
completed: 'finalized',
|
||||
rejected: 'rejected',
|
||||
hold: 'draft',
|
||||
};
|
||||
apiData.status = reverseStatusMap[data.status] || 'pending';
|
||||
}
|
||||
|
||||
if (data.bidInfo !== undefined) {
|
||||
apiData.site_name = data.bidInfo.projectName;
|
||||
apiData.registration_date = data.bidInfo.bidDate;
|
||||
}
|
||||
|
||||
// options 데이터 역변환
|
||||
const options: Record<string, unknown> = {};
|
||||
|
||||
if (data.summaryItems !== undefined) {
|
||||
options.summary_items = data.summaryItems.map((item) => ({
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
quantity: item.quantity,
|
||||
unit: item.unit,
|
||||
material_cost: item.materialCost,
|
||||
labor_cost: item.laborCost,
|
||||
total_cost: item.totalCost,
|
||||
remarks: item.remarks,
|
||||
}));
|
||||
}
|
||||
|
||||
if (data.expenseItems !== undefined) {
|
||||
options.expense_items = data.expenseItems.map((item) => ({
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
amount: item.amount,
|
||||
}));
|
||||
}
|
||||
|
||||
if (data.priceAdjustments !== undefined) {
|
||||
options.price_adjustments = data.priceAdjustments.map((item) => ({
|
||||
id: item.id,
|
||||
category: item.category,
|
||||
unit_price: item.unitPrice,
|
||||
coating: item.coating,
|
||||
batting: item.batting,
|
||||
box_reinforce: item.boxReinforce,
|
||||
painting: item.painting,
|
||||
total: item.total,
|
||||
}));
|
||||
}
|
||||
|
||||
if (Object.keys(options).length > 0) {
|
||||
apiData.options = options;
|
||||
}
|
||||
|
||||
return apiData;
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// API 함수 (quotes API 사용)
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* 건설 견적 목록 조회
|
||||
* GET /api/v1/quotes?quote_type=construction
|
||||
*/
|
||||
export async function getEstimateList(filter?: EstimateFilter): Promise<{
|
||||
success: boolean;
|
||||
data?: EstimateListResponse;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
let filtered = [...mockEstimates];
|
||||
const queryParams: Record<string, string> = {
|
||||
quote_type: 'construction', // 건설 견적만 조회
|
||||
};
|
||||
|
||||
// 검색 필터
|
||||
if (filter?.search) {
|
||||
const search = filter.search.toLowerCase();
|
||||
filtered = filtered.filter(
|
||||
(e) =>
|
||||
e.projectName.toLowerCase().includes(search) ||
|
||||
e.estimateCode.toLowerCase().includes(search) ||
|
||||
e.partnerName.toLowerCase().includes(search)
|
||||
);
|
||||
}
|
||||
// 검색
|
||||
if (filter?.search) queryParams.q = filter.search;
|
||||
|
||||
// 상태 필터
|
||||
// 필터
|
||||
if (filter?.status && filter.status !== 'all') {
|
||||
filtered = filtered.filter((e) => e.status === filter.status);
|
||||
// Estimate 상태 → Quote 상태로 변환
|
||||
const statusMap: Record<string, string> = {
|
||||
pending: 'pending',
|
||||
approval_waiting: 'sent',
|
||||
completed: 'finalized',
|
||||
rejected: 'rejected',
|
||||
hold: 'draft',
|
||||
};
|
||||
queryParams.status = statusMap[filter.status] || filter.status;
|
||||
}
|
||||
if (filter?.partnerId) queryParams.client_id = filter.partnerId;
|
||||
|
||||
// 거래처 필터
|
||||
if (filter?.partnerId) {
|
||||
filtered = filtered.filter((e) => e.partnerId === filter.partnerId);
|
||||
}
|
||||
// 날짜 범위
|
||||
if (filter?.startDate) queryParams.date_from = filter.startDate;
|
||||
if (filter?.endDate) queryParams.date_to = filter.endDate;
|
||||
|
||||
// 견적자 필터
|
||||
if (filter?.estimatorId) {
|
||||
filtered = filtered.filter((e) => e.estimatorId === filter.estimatorId);
|
||||
}
|
||||
|
||||
// 날짜 필터
|
||||
if (filter?.startDate) {
|
||||
filtered = filtered.filter((e) => e.createdAt >= filter.startDate!);
|
||||
}
|
||||
if (filter?.endDate) {
|
||||
filtered = filtered.filter((e) => e.createdAt <= filter.endDate!);
|
||||
}
|
||||
// 페이지네이션
|
||||
if (filter?.page) queryParams.page = String(filter.page);
|
||||
if (filter?.size) queryParams.size = String(filter.size);
|
||||
|
||||
// 정렬
|
||||
if (filter?.sortBy) {
|
||||
switch (filter.sortBy) {
|
||||
case 'latest':
|
||||
filtered.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
||||
break;
|
||||
case 'oldest':
|
||||
filtered.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
|
||||
break;
|
||||
case 'amountDesc':
|
||||
filtered.sort((a, b) => b.estimateAmount - a.estimateAmount);
|
||||
break;
|
||||
case 'amountAsc':
|
||||
filtered.sort((a, b) => a.estimateAmount - b.estimateAmount);
|
||||
break;
|
||||
case 'bidDateDesc':
|
||||
filtered.sort((a, b) => {
|
||||
if (!a.bidDate) return 1;
|
||||
if (!b.bidDate) return -1;
|
||||
return new Date(b.bidDate).getTime() - new Date(a.bidDate).getTime();
|
||||
});
|
||||
break;
|
||||
const sortMap: Record<string, { field: string; dir: string }> = {
|
||||
latest: { field: 'created_at', dir: 'desc' },
|
||||
oldest: { field: 'created_at', dir: 'asc' },
|
||||
amountDesc: { field: 'total_amount', dir: 'desc' },
|
||||
amountAsc: { field: 'total_amount', dir: 'asc' },
|
||||
bidDateDesc: { field: 'registration_date', dir: 'desc' },
|
||||
partnerNameAsc: { field: 'client_name', dir: 'asc' },
|
||||
partnerNameDesc: { field: 'client_name', dir: 'desc' },
|
||||
projectNameAsc: { field: 'site_name', dir: 'asc' },
|
||||
projectNameDesc: { field: 'site_name', dir: 'desc' },
|
||||
};
|
||||
const sort = sortMap[filter.sortBy];
|
||||
if (sort) {
|
||||
queryParams.sort_by = sort.field;
|
||||
queryParams.sort_order = sort.dir;
|
||||
}
|
||||
}
|
||||
|
||||
const page = filter?.page ?? 1;
|
||||
const size = filter?.size ?? 20;
|
||||
const start = (page - 1) * size;
|
||||
const paginatedItems = filtered.slice(start, start + size);
|
||||
const response = await apiClient.get<{
|
||||
success: boolean;
|
||||
data: {
|
||||
data: ApiQuote[];
|
||||
current_page: number;
|
||||
per_page: number;
|
||||
total: number;
|
||||
last_page: number;
|
||||
};
|
||||
}>('/quotes', { params: queryParams });
|
||||
|
||||
const paginatedData = response.data;
|
||||
const items = (paginatedData.data || []).map(transformQuoteToEstimate);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
items: paginatedItems,
|
||||
total: filtered.length,
|
||||
page,
|
||||
size,
|
||||
totalPages: Math.ceil(filtered.length / size),
|
||||
items,
|
||||
total: paginatedData.total || 0,
|
||||
page: paginatedData.current_page || 1,
|
||||
size: paginatedData.per_page || 20,
|
||||
totalPages: paginatedData.last_page || 1,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('getEstimateList error:', error);
|
||||
return { success: false, error: '견적 목록 조회에 실패했습니다.' };
|
||||
console.error('견적 목록 조회 오류:', error);
|
||||
return { success: false, error: '견적 목록을 불러오는데 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// 견적 상세 조회
|
||||
export async function getEstimate(
|
||||
id: string
|
||||
): Promise<{ success: boolean; data?: Estimate; error?: string }> {
|
||||
/**
|
||||
* 견적 단건 조회
|
||||
* GET /api/v1/quotes/{id}
|
||||
*/
|
||||
export async function getEstimate(id: string): Promise<{
|
||||
success: boolean;
|
||||
data?: Estimate;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
const estimate = mockEstimates.find((e) => e.id === id);
|
||||
|
||||
if (!estimate) {
|
||||
return { success: false, error: '견적을 찾을 수 없습니다.' };
|
||||
}
|
||||
|
||||
return { success: true, data: estimate };
|
||||
const response = await apiClient.get<{ success: boolean; data: ApiQuote }>(`/quotes/${id}`);
|
||||
return { success: true, data: transformQuoteToEstimate(response.data) };
|
||||
} catch (error) {
|
||||
console.error('getEstimate error:', error);
|
||||
return { success: false, error: '견적 조회에 실패했습니다.' };
|
||||
console.error('견적 조회 오류:', error);
|
||||
return { success: false, error: '견적 정보를 찾을 수 없습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// 견적 통계 조회
|
||||
export async function getEstimateStats(): Promise<{ success: boolean; data?: EstimateStats; error?: string }> {
|
||||
/**
|
||||
* 견적 상세 조회 (첨부 정보 포함)
|
||||
* GET /api/v1/quotes/{id}
|
||||
*/
|
||||
export async function getEstimateDetail(id: string): Promise<{
|
||||
success: boolean;
|
||||
data?: EstimateDetail;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
const total = mockEstimates.length;
|
||||
const pending = mockEstimates.filter((e) => e.status === 'pending').length;
|
||||
const completed = mockEstimates.filter((e) => e.status === 'completed').length;
|
||||
const response = await apiClient.get<{ success: boolean; data: ApiQuote }>(`/quotes/${id}`);
|
||||
return { success: true, data: transformQuoteToEstimateDetail(response.data) };
|
||||
} catch (error) {
|
||||
console.error('견적 상세 조회 오류:', error);
|
||||
return { success: false, error: '견적 상세 정보를 불러오는데 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 견적 통계 조회
|
||||
* GET /api/v1/quotes/stats (건설용)
|
||||
* 현재는 목록 조회로 대체
|
||||
*/
|
||||
export async function getEstimateStats(): Promise<{
|
||||
success: boolean;
|
||||
data?: EstimateStats;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
// 통계 API가 없으므로 목록 조회로 대체
|
||||
const [allResponse, pendingResponse] = await Promise.all([
|
||||
apiClient.get<{ success: boolean; data: { total: number } }>('/quotes', {
|
||||
params: { quote_type: 'construction', size: '1' },
|
||||
}),
|
||||
apiClient.get<{ success: boolean; data: { total: number } }>('/quotes', {
|
||||
params: { quote_type: 'construction', status: 'pending', size: '1' },
|
||||
}),
|
||||
]);
|
||||
|
||||
const total = allResponse.data?.total || 0;
|
||||
const pending = pendingResponse.data?.total || 0;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
total,
|
||||
pending,
|
||||
completed,
|
||||
completed: total - pending,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('getEstimateStats error:', error);
|
||||
return { success: false, error: '통계 조회에 실패했습니다.' };
|
||||
console.error('견적 통계 조회 오류:', error);
|
||||
return { success: false, error: '통계를 불러오는데 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// 견적 삭제
|
||||
export async function deleteEstimate(id: string): Promise<{ success: boolean; error?: string }> {
|
||||
/**
|
||||
* 견적 등록
|
||||
* POST /api/v1/quotes
|
||||
*/
|
||||
export async function createEstimate(data: EstimateDetailFormData): Promise<{
|
||||
success: boolean;
|
||||
data?: Estimate;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
console.log('Delete estimate:', id);
|
||||
const apiData = {
|
||||
...transformToApiRequest(data),
|
||||
quote_type: 'construction', // 건설 견적으로 생성
|
||||
};
|
||||
const response = await apiClient.post<ApiQuote>('/quotes', apiData);
|
||||
return { success: true, data: transformQuoteToEstimate(response) };
|
||||
} catch (error) {
|
||||
console.error('견적 등록 오류:', error);
|
||||
return { success: false, error: '견적 등록에 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 견적 수정
|
||||
* PUT /api/v1/quotes/{id}
|
||||
*/
|
||||
export async function updateEstimate(
|
||||
id: string,
|
||||
data: Partial<EstimateDetailFormData>
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
data?: Estimate;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
const apiData = transformToApiRequest(data);
|
||||
const response = await apiClient.put<ApiQuote>(`/quotes/${id}`, apiData);
|
||||
return { success: true, data: transformQuoteToEstimate(response) };
|
||||
} catch (error) {
|
||||
console.error('견적 수정 오류:', error);
|
||||
return { success: false, error: '견적 수정에 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 견적 삭제
|
||||
* DELETE /api/v1/quotes/{id}
|
||||
*/
|
||||
export async function deleteEstimate(id: string): Promise<{
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
await apiClient.delete(`/quotes/${id}`);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('deleteEstimate error:', error);
|
||||
console.error('견적 삭제 오류:', error);
|
||||
return { success: false, error: '견적 삭제에 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// 견적 일괄 삭제
|
||||
export async function deleteEstimates(ids: string[]): Promise<{ success: boolean; deletedCount?: number; error?: string }> {
|
||||
/**
|
||||
* 견적 일괄 삭제
|
||||
* DELETE /api/v1/quotes/bulk
|
||||
*/
|
||||
export async function deleteEstimates(ids: string[]): Promise<{
|
||||
success: boolean;
|
||||
deletedCount?: number;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
console.log('Delete estimates:', ids);
|
||||
await apiClient.delete('/quotes/bulk', {
|
||||
data: { ids: ids.map((id) => Number(id)) },
|
||||
});
|
||||
return { success: true, deletedCount: ids.length };
|
||||
} catch (error) {
|
||||
console.error('deleteEstimates error:', error);
|
||||
console.error('견적 일괄 삭제 오류:', error);
|
||||
return { success: false, error: '일괄 삭제에 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
@@ -1,400 +1,452 @@
|
||||
'use server';
|
||||
|
||||
import type { HandoverReport, HandoverReportStats, HandoverReportDetail, HandoverReportFormData } from './types';
|
||||
import type {
|
||||
HandoverReport,
|
||||
HandoverReportDetail,
|
||||
HandoverReportStats,
|
||||
HandoverReportFormData,
|
||||
ConstructionManager,
|
||||
ContractItem,
|
||||
ExternalEquipmentCost,
|
||||
} from './types';
|
||||
import { apiClient } from '@/lib/api';
|
||||
|
||||
// 목업 데이터
|
||||
const MOCK_REPORTS: HandoverReport[] = [
|
||||
{
|
||||
id: '1',
|
||||
reportNumber: '123123',
|
||||
partnerName: '통신공사',
|
||||
siteName: '서울역사 통신공사',
|
||||
contractManagerName: '홍길동',
|
||||
constructionPMName: '김PM',
|
||||
totalSites: 21,
|
||||
contractAmount: 105800000,
|
||||
contractStartDate: '2025-12-12',
|
||||
contractEndDate: '2026-12-12',
|
||||
status: 'pending',
|
||||
contractId: '1',
|
||||
createdAt: '2025-01-01',
|
||||
updatedAt: '2025-01-01',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
reportNumber: '123124',
|
||||
partnerName: '야사건설',
|
||||
siteName: '부산항 건설현장',
|
||||
contractManagerName: '김철수',
|
||||
constructionPMName: '이PM',
|
||||
totalSites: 15,
|
||||
contractAmount: 10500000,
|
||||
contractStartDate: '2025-11-01',
|
||||
contractEndDate: '2026-11-01',
|
||||
status: 'completed',
|
||||
contractId: '2',
|
||||
createdAt: '2025-01-02',
|
||||
updatedAt: '2025-01-02',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
reportNumber: '123125',
|
||||
partnerName: '여의건설',
|
||||
siteName: '인천공항 확장공사',
|
||||
contractManagerName: '이영희',
|
||||
constructionPMName: '박PM',
|
||||
totalSites: 30,
|
||||
contractAmount: 10000000,
|
||||
contractStartDate: '2025-10-15',
|
||||
contractEndDate: '2026-10-15',
|
||||
status: 'pending',
|
||||
contractId: '3',
|
||||
createdAt: '2025-01-03',
|
||||
updatedAt: '2025-01-03',
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
reportNumber: '123126',
|
||||
partnerName: '통신공사',
|
||||
siteName: '대전역 리모델링',
|
||||
contractManagerName: '홍길동',
|
||||
constructionPMName: '김PM',
|
||||
totalSites: 18,
|
||||
contractAmount: 10000000,
|
||||
contractStartDate: '2025-09-20',
|
||||
contractEndDate: '2026-03-20',
|
||||
status: 'completed',
|
||||
contractId: '4',
|
||||
createdAt: '2025-01-04',
|
||||
updatedAt: '2025-01-04',
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
reportNumber: '123127',
|
||||
partnerName: '야사건설',
|
||||
siteName: '광주 신축현장',
|
||||
contractManagerName: '김철수',
|
||||
constructionPMName: '이PM',
|
||||
totalSites: 17,
|
||||
contractAmount: 10500000,
|
||||
contractStartDate: '2025-08-01',
|
||||
contractEndDate: '2026-08-01',
|
||||
status: 'pending',
|
||||
contractId: '5',
|
||||
createdAt: '2025-01-05',
|
||||
updatedAt: '2025-01-05',
|
||||
},
|
||||
{
|
||||
id: '6',
|
||||
reportNumber: '123128',
|
||||
partnerName: '여의건설',
|
||||
siteName: '세종시 행정타운',
|
||||
contractManagerName: '이영희',
|
||||
constructionPMName: '박PM',
|
||||
totalSites: 25,
|
||||
contractAmount: 100000000,
|
||||
contractStartDate: '2025-07-15',
|
||||
contractEndDate: '2026-07-15',
|
||||
status: 'completed',
|
||||
contractId: '6',
|
||||
createdAt: '2025-01-06',
|
||||
updatedAt: '2025-01-06',
|
||||
},
|
||||
{
|
||||
id: '7',
|
||||
reportNumber: '123129',
|
||||
partnerName: '통신공사',
|
||||
siteName: '제주 관광단지',
|
||||
contractManagerName: '홍길동',
|
||||
constructionPMName: null,
|
||||
totalSites: 12,
|
||||
contractAmount: 105800000,
|
||||
contractStartDate: '2025-06-01',
|
||||
contractEndDate: '2026-06-01',
|
||||
status: 'pending',
|
||||
contractId: '7',
|
||||
createdAt: '2025-01-07',
|
||||
updatedAt: '2025-01-07',
|
||||
},
|
||||
];
|
||||
/**
|
||||
* 주일 기업 - 인수인계보고서관리 Server Actions
|
||||
* 표준화된 apiClient 사용 버전
|
||||
*/
|
||||
|
||||
interface GetHandoverReportListParams {
|
||||
// ========================================
|
||||
// API 응답 타입
|
||||
// ========================================
|
||||
|
||||
interface ApiHandoverReport {
|
||||
id: number;
|
||||
report_number: string;
|
||||
partner_name: string | null;
|
||||
site_name: string;
|
||||
contract_manager_name: string | null;
|
||||
construction_pm_name: string | null;
|
||||
construction_pm_id: number | null;
|
||||
total_sites: number;
|
||||
contract_amount: number;
|
||||
contract_date: string | null;
|
||||
contract_start_date: string | null;
|
||||
contract_end_date: string | null;
|
||||
completion_date: string | null;
|
||||
status: 'pending' | 'completed';
|
||||
contract_id: number | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
// 상세 조회 시 포함
|
||||
managers?: ApiManager[];
|
||||
items?: ApiContractItem[];
|
||||
has_secondary_piping?: boolean;
|
||||
secondary_piping_amount?: number;
|
||||
secondary_piping_note?: string | null;
|
||||
has_coating?: boolean;
|
||||
coating_amount?: number;
|
||||
coating_note?: string | null;
|
||||
external_equipment_cost?: ApiExternalEquipmentCost;
|
||||
special_notes?: string | null;
|
||||
}
|
||||
|
||||
interface ApiManager {
|
||||
id: number;
|
||||
name: string;
|
||||
non_performance_reason: string | null;
|
||||
signature: string | null;
|
||||
}
|
||||
|
||||
interface ApiContractItem {
|
||||
id: number;
|
||||
item_no: number;
|
||||
name: string;
|
||||
product: string | null;
|
||||
quantity: number;
|
||||
remark: string | null;
|
||||
}
|
||||
|
||||
interface ApiExternalEquipmentCost {
|
||||
shipping_cost: number;
|
||||
high_altitude_work: number;
|
||||
public_expense: number;
|
||||
}
|
||||
|
||||
interface ApiHandoverReportStats {
|
||||
total_count: number;
|
||||
pending_count: number;
|
||||
completed_count: number;
|
||||
total_amount?: number;
|
||||
total_sites?: number;
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 타입 변환 함수
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* API 응답 → HandoverReport 타입 변환 (목록용)
|
||||
*/
|
||||
function transformHandoverReport(apiData: ApiHandoverReport): HandoverReport {
|
||||
return {
|
||||
id: String(apiData.id),
|
||||
reportNumber: apiData.report_number || '',
|
||||
partnerName: apiData.partner_name || '',
|
||||
siteName: apiData.site_name || '',
|
||||
contractManagerName: apiData.contract_manager_name || '',
|
||||
constructionPMName: apiData.construction_pm_name || null,
|
||||
totalSites: apiData.total_sites || 0,
|
||||
contractAmount: apiData.contract_amount || 0,
|
||||
contractStartDate: apiData.contract_start_date || null,
|
||||
contractEndDate: apiData.contract_end_date || null,
|
||||
status: apiData.status || 'pending',
|
||||
contractId: apiData.contract_id ? String(apiData.contract_id) : '',
|
||||
createdAt: apiData.created_at || '',
|
||||
updatedAt: apiData.updated_at || '',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* API 응답 → HandoverReportDetail 타입 변환 (상세용)
|
||||
*/
|
||||
function transformHandoverReportDetail(apiData: ApiHandoverReport): HandoverReportDetail {
|
||||
// 공사담당자 목록 변환
|
||||
const constructionManagers: ConstructionManager[] = (apiData.managers || []).map((m) => ({
|
||||
id: String(m.id),
|
||||
name: m.name || '',
|
||||
nonPerformanceReason: m.non_performance_reason || '',
|
||||
signature: m.signature || null,
|
||||
}));
|
||||
|
||||
// 계약 ITEM 목록 변환
|
||||
const contractItems: ContractItem[] = (apiData.items || []).map((item) => ({
|
||||
id: String(item.id),
|
||||
no: item.item_no || 0,
|
||||
name: item.name || '',
|
||||
product: item.product || '',
|
||||
quantity: item.quantity || 0,
|
||||
remark: item.remark || '',
|
||||
}));
|
||||
|
||||
// 장비 외 실행금액 변환
|
||||
const externalCost = apiData.external_equipment_cost;
|
||||
const externalEquipmentCost: ExternalEquipmentCost = externalCost
|
||||
? {
|
||||
shippingCost: externalCost.shipping_cost || 0,
|
||||
highAltitudeWork: externalCost.high_altitude_work || 0,
|
||||
publicExpense: externalCost.public_expense || 0,
|
||||
}
|
||||
: {
|
||||
shippingCost: 0,
|
||||
highAltitudeWork: 0,
|
||||
publicExpense: 0,
|
||||
};
|
||||
|
||||
return {
|
||||
id: String(apiData.id),
|
||||
reportNumber: apiData.report_number || '',
|
||||
partnerName: apiData.partner_name || '',
|
||||
siteName: apiData.site_name || '',
|
||||
contractManagerName: apiData.contract_manager_name || '',
|
||||
constructionPMName: apiData.construction_pm_name || null,
|
||||
constructionPMId: apiData.construction_pm_id ? String(apiData.construction_pm_id) : null,
|
||||
totalSites: apiData.total_sites || 0,
|
||||
contractAmount: apiData.contract_amount || 0,
|
||||
contractDate: apiData.contract_date || null,
|
||||
contractStartDate: apiData.contract_start_date || null,
|
||||
contractEndDate: apiData.contract_end_date || null,
|
||||
completionDate: apiData.completion_date || null,
|
||||
status: apiData.status || 'pending',
|
||||
contractId: apiData.contract_id ? String(apiData.contract_id) : '',
|
||||
createdAt: apiData.created_at || '',
|
||||
updatedAt: apiData.updated_at || '',
|
||||
constructionManagers,
|
||||
contractItems,
|
||||
hasSecondaryPiping: apiData.has_secondary_piping || false,
|
||||
secondaryPipingAmount: apiData.secondary_piping_amount || 0,
|
||||
secondaryPipingNote: apiData.secondary_piping_note || '',
|
||||
hasCoating: apiData.has_coating || false,
|
||||
coatingAmount: apiData.coating_amount || 0,
|
||||
coatingNote: apiData.coating_note || '',
|
||||
externalEquipmentCost,
|
||||
specialNotes: apiData.special_notes || '',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* HandoverReportFormData → API 요청 데이터 변환
|
||||
*/
|
||||
function transformToApiRequest(data: Partial<HandoverReportFormData>): Record<string, unknown> {
|
||||
const apiData: Record<string, unknown> = {};
|
||||
|
||||
if (data.reportNumber !== undefined) apiData.report_number = data.reportNumber;
|
||||
if (data.partnerName !== undefined) apiData.partner_name = data.partnerName || null;
|
||||
if (data.siteName !== undefined) apiData.site_name = data.siteName;
|
||||
if (data.contractManagerName !== undefined) apiData.contract_manager_name = data.contractManagerName || null;
|
||||
if (data.contractDate !== undefined) apiData.contract_date = data.contractDate || null;
|
||||
if (data.totalSites !== undefined) apiData.total_sites = data.totalSites;
|
||||
if (data.contractStartDate !== undefined) apiData.contract_start_date = data.contractStartDate || null;
|
||||
if (data.contractEndDate !== undefined) apiData.contract_end_date = data.contractEndDate || null;
|
||||
if (data.contractAmount !== undefined) apiData.contract_amount = data.contractAmount;
|
||||
if (data.constructionPMId !== undefined) apiData.construction_pm_id = data.constructionPMId || null;
|
||||
if (data.constructionPMName !== undefined) apiData.construction_pm_name = data.constructionPMName || null;
|
||||
if (data.status !== undefined) apiData.status = data.status;
|
||||
if (data.hasSecondaryPiping !== undefined) apiData.has_secondary_piping = data.hasSecondaryPiping;
|
||||
if (data.secondaryPipingNote !== undefined) apiData.secondary_piping_note = data.secondaryPipingNote || null;
|
||||
if (data.hasCoating !== undefined) apiData.has_coating = data.hasCoating;
|
||||
if (data.coatingNote !== undefined) apiData.coating_note = data.coatingNote || null;
|
||||
if (data.specialNotes !== undefined) apiData.special_notes = data.specialNotes || null;
|
||||
|
||||
// 장비 외 실행금액 변환
|
||||
if (data.externalEquipmentCost !== undefined) {
|
||||
apiData.external_equipment_cost = {
|
||||
shipping_cost: data.externalEquipmentCost.shippingCost,
|
||||
high_altitude_work: data.externalEquipmentCost.highAltitudeWork,
|
||||
public_expense: data.externalEquipmentCost.publicExpense,
|
||||
};
|
||||
}
|
||||
|
||||
// 공사담당자 변환
|
||||
if (data.constructionManagers !== undefined) {
|
||||
apiData.managers = data.constructionManagers.map((m) => ({
|
||||
name: m.name,
|
||||
non_performance_reason: m.nonPerformanceReason || null,
|
||||
signature: m.signature || null,
|
||||
}));
|
||||
}
|
||||
|
||||
// 계약 ITEM 변환
|
||||
if (data.contractItems !== undefined) {
|
||||
apiData.items = data.contractItems.map((item, index) => ({
|
||||
item_no: item.no || index + 1,
|
||||
name: item.name,
|
||||
product: item.product || null,
|
||||
quantity: item.quantity,
|
||||
remark: item.remark || null,
|
||||
}));
|
||||
}
|
||||
|
||||
return apiData;
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// API 함수
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* 인수인계보고서 목록 조회
|
||||
* GET /api/v1/construction/handover-reports
|
||||
*/
|
||||
export async function getHandoverReportList(params?: {
|
||||
size?: number;
|
||||
page?: number;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
}
|
||||
|
||||
interface GetHandoverReportListResult {
|
||||
search?: string;
|
||||
status?: string;
|
||||
partnerId?: string;
|
||||
contractManagerId?: string;
|
||||
constructionPMId?: string;
|
||||
sortBy?: string;
|
||||
}): Promise<{
|
||||
success: boolean;
|
||||
data?: {
|
||||
items: HandoverReport[];
|
||||
total: number;
|
||||
page: number;
|
||||
size: number;
|
||||
totalPages: number;
|
||||
};
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export async function getHandoverReportList(
|
||||
params: GetHandoverReportListParams = {}
|
||||
): Promise<GetHandoverReportListResult> {
|
||||
}> {
|
||||
try {
|
||||
// 실제 API 호출 시 여기에 구현
|
||||
// const response = await fetch(`/api/v1/handover-reports?...`);
|
||||
const queryParams: Record<string, string> = {};
|
||||
|
||||
// 페이지네이션
|
||||
if (params?.page) queryParams.page = String(params.page);
|
||||
if (params?.size) queryParams.per_page = String(params.size);
|
||||
|
||||
// 검색
|
||||
if (params?.search) queryParams.search = params.search;
|
||||
|
||||
// 필터
|
||||
if (params?.status && params.status !== 'all') queryParams.status = params.status;
|
||||
if (params?.partnerId && params.partnerId !== 'all') queryParams.partner_id = params.partnerId;
|
||||
if (params?.contractManagerId && params.contractManagerId !== 'all') {
|
||||
queryParams.contract_manager_id = params.contractManagerId;
|
||||
}
|
||||
if (params?.constructionPMId && params.constructionPMId !== 'all') {
|
||||
queryParams.construction_pm_id = params.constructionPMId;
|
||||
}
|
||||
|
||||
// 날짜 범위
|
||||
if (params?.startDate) queryParams.start_date = params.startDate;
|
||||
if (params?.endDate) queryParams.end_date = params.endDate;
|
||||
|
||||
// 정렬
|
||||
if (params?.sortBy) {
|
||||
const sortMap: Record<string, { field: string; dir: string }> = {
|
||||
contractDateDesc: { field: 'contract_start_date', dir: 'desc' },
|
||||
contractDateAsc: { field: 'contract_start_date', dir: 'asc' },
|
||||
partnerNameAsc: { field: 'partner_name', dir: 'asc' },
|
||||
partnerNameDesc: { field: 'partner_name', dir: 'desc' },
|
||||
siteNameAsc: { field: 'site_name', dir: 'asc' },
|
||||
siteNameDesc: { field: 'site_name', dir: 'desc' },
|
||||
};
|
||||
const sort = sortMap[params.sortBy];
|
||||
if (sort) {
|
||||
queryParams.sort_by = sort.field;
|
||||
queryParams.sort_dir = sort.dir;
|
||||
}
|
||||
}
|
||||
|
||||
const response = await apiClient.get<{
|
||||
data: ApiHandoverReport[];
|
||||
current_page: number;
|
||||
per_page: number;
|
||||
total: number;
|
||||
last_page: number;
|
||||
}>('/construction/handover-reports', { params: queryParams });
|
||||
|
||||
const items = (response.data || []).map(transformHandoverReport);
|
||||
|
||||
// 목업 데이터 반환
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
items: MOCK_REPORTS,
|
||||
total: MOCK_REPORTS.length,
|
||||
page: params.page || 1,
|
||||
size: params.size || 20,
|
||||
items,
|
||||
total: response.total || 0,
|
||||
page: response.current_page || 1,
|
||||
size: response.per_page || 20,
|
||||
totalPages: response.last_page || 1,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch handover report list:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: '인수인계보고서 목록을 불러오는데 실패했습니다.',
|
||||
};
|
||||
console.error('인수인계보고서 목록 조회 오류:', error);
|
||||
return { success: false, error: '인수인계보고서 목록을 불러오는데 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
interface GetHandoverReportStatsResult {
|
||||
/**
|
||||
* 인수인계보고서 통계 조회
|
||||
* GET /api/v1/construction/handover-reports/stats
|
||||
*/
|
||||
export async function getHandoverReportStats(): Promise<{
|
||||
success: boolean;
|
||||
data?: HandoverReportStats;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export async function getHandoverReportStats(): Promise<GetHandoverReportStatsResult> {
|
||||
}> {
|
||||
try {
|
||||
// 실제 API 호출 시 여기에 구현
|
||||
|
||||
// 목업 통계 반환
|
||||
const pending = MOCK_REPORTS.filter(r => r.status === 'pending').length;
|
||||
const completed = MOCK_REPORTS.filter(r => r.status === 'completed').length;
|
||||
const response = await apiClient.get<ApiHandoverReportStats>('/construction/handover-reports/stats');
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
total: MOCK_REPORTS.length,
|
||||
pending,
|
||||
completed,
|
||||
total: response.total_count || 0,
|
||||
pending: response.pending_count || 0,
|
||||
completed: response.completed_count || 0,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch handover report stats:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: '통계를 불러오는데 실패했습니다.',
|
||||
};
|
||||
console.error('인수인계보고서 통계 조회 오류:', error);
|
||||
return { success: false, error: '통계를 불러오는데 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
interface DeleteHandoverReportResult {
|
||||
/**
|
||||
* 인수인계보고서 삭제
|
||||
* DELETE /api/v1/construction/handover-reports/{id}
|
||||
*/
|
||||
export async function deleteHandoverReport(id: string): Promise<{
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export async function deleteHandoverReport(id: string): Promise<DeleteHandoverReportResult> {
|
||||
}> {
|
||||
try {
|
||||
// 실제 API 호출 시 여기에 구현
|
||||
console.log('Deleting handover report:', id);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
await apiClient.delete(`/construction/handover-reports/${id}`);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('Failed to delete handover report:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: '삭제에 실패했습니다.',
|
||||
};
|
||||
console.error('인수인계보고서 삭제 오류:', error);
|
||||
return { success: false, error: '삭제에 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
interface DeleteHandoverReportsResult {
|
||||
/**
|
||||
* 인수인계보고서 일괄 삭제
|
||||
* DELETE /api/v1/construction/handover-reports/bulk
|
||||
*/
|
||||
export async function deleteHandoverReports(ids: string[]): Promise<{
|
||||
success: boolean;
|
||||
deletedCount?: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export async function deleteHandoverReports(ids: string[]): Promise<DeleteHandoverReportsResult> {
|
||||
}> {
|
||||
try {
|
||||
// 실제 API 호출 시 여기에 구현
|
||||
console.log('Deleting handover reports:', ids);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
deletedCount: ids.length,
|
||||
};
|
||||
await apiClient.delete('/construction/handover-reports/bulk', {
|
||||
data: { ids: ids.map((id) => Number(id)) },
|
||||
});
|
||||
return { success: true, deletedCount: ids.length };
|
||||
} catch (error) {
|
||||
console.error('Failed to delete handover reports:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: '일괄 삭제에 실패했습니다.',
|
||||
};
|
||||
console.error('인수인계보고서 일괄 삭제 오류:', error);
|
||||
return { success: false, error: '일괄 삭제에 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// 목업 상세 데이터
|
||||
const MOCK_REPORT_DETAILS: Record<string, HandoverReportDetail> = {
|
||||
'1': {
|
||||
id: '1',
|
||||
reportNumber: '123123',
|
||||
partnerName: '통신공사',
|
||||
siteName: '서울역사 통신공사',
|
||||
contractManagerName: '홍길동',
|
||||
constructionPMName: '김PM',
|
||||
constructionPMId: 'pm1',
|
||||
totalSites: 21,
|
||||
contractAmount: 105800000,
|
||||
contractDate: '2025-12-12',
|
||||
contractStartDate: '2026-01-01',
|
||||
contractEndDate: '2026-12-10',
|
||||
status: 'pending',
|
||||
contractId: '1',
|
||||
createdAt: '2025-01-01',
|
||||
updatedAt: '2025-01-01',
|
||||
completionDate: '2026-05-01',
|
||||
constructionManagers: [
|
||||
{ id: 'mgr1', name: '홍길동', isNonPerformanceUsed: false },
|
||||
{ id: 'mgr2', name: '김철수', isNonPerformanceUsed: true },
|
||||
],
|
||||
contractItems: [
|
||||
{ id: 'item1', no: 1, name: '접지방화서터', product: '제품', quantity: 1000, remark: '품질인증적용' },
|
||||
{ id: 'item2', no: 2, name: '스크린방화서터', product: '제품', quantity: 111, remark: '품질인증적용' },
|
||||
],
|
||||
hasSecondaryPiping: true,
|
||||
secondaryPipingAmount: 1200000,
|
||||
hasCoating: true,
|
||||
coatingAmount: 500000,
|
||||
externalEquipmentCost: {
|
||||
shippingCost: 1500000,
|
||||
highAltitudeWork: 800000,
|
||||
publicExpense: 10000000,
|
||||
},
|
||||
specialNotes: '특이사항 내용이 여기에 표시됩니다.',
|
||||
},
|
||||
'2': {
|
||||
id: '2',
|
||||
reportNumber: '123124',
|
||||
partnerName: '야사건설',
|
||||
siteName: '부산항 건설현장',
|
||||
contractManagerName: '김철수',
|
||||
constructionPMName: '이PM',
|
||||
constructionPMId: 'pm2',
|
||||
totalSites: 15,
|
||||
contractAmount: 10500000,
|
||||
contractDate: '2025-11-01',
|
||||
contractStartDate: '2025-11-01',
|
||||
contractEndDate: '2026-11-01',
|
||||
status: 'completed',
|
||||
contractId: '2',
|
||||
createdAt: '2025-01-02',
|
||||
updatedAt: '2025-01-02',
|
||||
completionDate: '2026-04-01',
|
||||
constructionManagers: [
|
||||
{ id: 'mgr3', name: '이영희', isNonPerformanceUsed: false },
|
||||
],
|
||||
contractItems: [
|
||||
{ id: 'item3', no: 1, name: '방화문', product: '제품A', quantity: 500, remark: '' },
|
||||
],
|
||||
hasSecondaryPiping: false,
|
||||
secondaryPipingAmount: 0,
|
||||
hasCoating: false,
|
||||
coatingAmount: 0,
|
||||
externalEquipmentCost: {
|
||||
shippingCost: 500000,
|
||||
highAltitudeWork: 0,
|
||||
publicExpense: 2000000,
|
||||
},
|
||||
specialNotes: '',
|
||||
},
|
||||
};
|
||||
|
||||
interface GetHandoverReportDetailResult {
|
||||
/**
|
||||
* 인수인계보고서 상세 조회
|
||||
* GET /api/v1/construction/handover-reports/{id}
|
||||
*/
|
||||
export async function getHandoverReportDetail(id: string): Promise<{
|
||||
success: boolean;
|
||||
data?: HandoverReportDetail;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export async function getHandoverReportDetail(id: string): Promise<GetHandoverReportDetailResult> {
|
||||
}> {
|
||||
try {
|
||||
// 실제 API 호출 시 여기에 구현
|
||||
// const response = await fetch(`/api/v1/handover-reports/${id}`);
|
||||
|
||||
const detail = MOCK_REPORT_DETAILS[id];
|
||||
|
||||
if (!detail) {
|
||||
// 목록 데이터에서 기본 상세 생성
|
||||
const report = MOCK_REPORTS.find(r => r.id === id);
|
||||
if (report) {
|
||||
const generatedDetail: HandoverReportDetail = {
|
||||
...report,
|
||||
contractDate: report.contractStartDate,
|
||||
constructionPMId: 'pm1',
|
||||
completionDate: null,
|
||||
constructionManagers: [],
|
||||
contractItems: [],
|
||||
hasSecondaryPiping: false,
|
||||
secondaryPipingAmount: 0,
|
||||
hasCoating: false,
|
||||
coatingAmount: 0,
|
||||
externalEquipmentCost: {
|
||||
shippingCost: 0,
|
||||
highAltitudeWork: 0,
|
||||
publicExpense: 0,
|
||||
},
|
||||
specialNotes: '',
|
||||
};
|
||||
return {
|
||||
success: true,
|
||||
data: generatedDetail,
|
||||
};
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
error: '인수인계보고서를 찾을 수 없습니다.',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: detail,
|
||||
};
|
||||
const response = await apiClient.get<ApiHandoverReport>(`/construction/handover-reports/${id}`);
|
||||
return { success: true, data: transformHandoverReportDetail(response) };
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch handover report detail:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: '인수인계보고서 상세 정보를 불러오는데 실패했습니다.',
|
||||
};
|
||||
console.error('인수인계보고서 상세 조회 오류:', error);
|
||||
return { success: false, error: '인수인계보고서를 찾을 수 없습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
interface UpdateHandoverReportResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 인수인계보고서 수정
|
||||
* PUT /api/v1/construction/handover-reports/{id}
|
||||
*/
|
||||
export async function updateHandoverReport(
|
||||
id: string,
|
||||
data: HandoverReportFormData
|
||||
): Promise<UpdateHandoverReportResult> {
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
data?: HandoverReportDetail;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
// 실제 API 호출 시 여기에 구현
|
||||
console.log('Updating handover report:', id, data);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
const apiData = transformToApiRequest(data);
|
||||
const response = await apiClient.put<ApiHandoverReport>(`/construction/handover-reports/${id}`, apiData);
|
||||
return { success: true, data: transformHandoverReportDetail(response) };
|
||||
} catch (error) {
|
||||
console.error('Failed to update handover report:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: '수정에 실패했습니다.',
|
||||
};
|
||||
console.error('인수인계보고서 수정 오류:', error);
|
||||
return { success: false, error: '수정에 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 인수인계보고서 등록
|
||||
* POST /api/v1/construction/handover-reports
|
||||
*/
|
||||
export async function createHandoverReport(
|
||||
data: HandoverReportFormData
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
data?: HandoverReportDetail;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
const apiData = transformToApiRequest(data);
|
||||
const response = await apiClient.post<ApiHandoverReport>('/construction/handover-reports', apiData);
|
||||
return { success: true, data: transformHandoverReportDetail(response) };
|
||||
} catch (error) {
|
||||
console.error('인수인계보고서 등록 오류:', error);
|
||||
return { success: false, error: '등록에 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
@@ -1,187 +1,250 @@
|
||||
'use server';
|
||||
|
||||
import type { Item, ItemStats, ItemListParams, ItemListResponse, ItemDetail, ItemFormData, OrderItem } from './types';
|
||||
import type { Item, ItemStats, ItemListParams, ItemListResponse, ItemDetail, ItemFormData, OrderItem, ItemType, Specification, OrderType as FrontOrderType, ItemStatus } from './types';
|
||||
import { apiClient } from '@/lib/api';
|
||||
|
||||
// 목데이터
|
||||
const mockItems: Item[] = [
|
||||
{
|
||||
id: '1',
|
||||
itemNumber: '123123',
|
||||
itemType: '제품',
|
||||
categoryId: '1',
|
||||
categoryName: '카테고리명',
|
||||
itemName: '품목명',
|
||||
specification: '인정',
|
||||
unit: 'SET',
|
||||
orderType: '외주발주',
|
||||
status: '승인',
|
||||
createdAt: '2026-01-01T10:00:00Z',
|
||||
updatedAt: '2026-01-01T10:00:00Z',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
itemNumber: '123124',
|
||||
itemType: '부품',
|
||||
categoryId: '2',
|
||||
categoryName: '모터',
|
||||
itemName: '소형 모터 A',
|
||||
specification: '비인정',
|
||||
unit: 'SET',
|
||||
orderType: '외주발주',
|
||||
status: '승인',
|
||||
createdAt: '2026-01-02T11:00:00Z',
|
||||
updatedAt: '2026-01-02T11:00:00Z',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
itemNumber: '123125',
|
||||
itemType: '소모품',
|
||||
categoryId: '3',
|
||||
categoryName: '공정자재',
|
||||
itemName: '절연테이프',
|
||||
specification: '인정',
|
||||
unit: 'SET',
|
||||
orderType: '외주발주',
|
||||
status: '승인',
|
||||
createdAt: '2026-01-03T09:00:00Z',
|
||||
updatedAt: '2026-01-03T09:00:00Z',
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
itemNumber: '123126',
|
||||
itemType: '공과',
|
||||
categoryId: '4',
|
||||
categoryName: '철물',
|
||||
itemName: '볼트 세트',
|
||||
specification: '비인정',
|
||||
unit: 'EA',
|
||||
orderType: '경품발주',
|
||||
status: '작업',
|
||||
createdAt: '2026-01-03T10:00:00Z',
|
||||
updatedAt: '2026-01-03T10:00:00Z',
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
itemNumber: '123127',
|
||||
itemType: '부품',
|
||||
categoryId: '1',
|
||||
categoryName: '슬라이드 OPEN 사이즈',
|
||||
itemName: '슬라이드 레일',
|
||||
specification: '인정',
|
||||
unit: 'EA',
|
||||
orderType: '원자재발주',
|
||||
status: '작업',
|
||||
createdAt: '2026-01-04T08:00:00Z',
|
||||
updatedAt: '2026-01-04T08:00:00Z',
|
||||
},
|
||||
{
|
||||
id: '6',
|
||||
itemNumber: '123128',
|
||||
itemType: '소모품',
|
||||
categoryId: '3',
|
||||
categoryName: '공정자재',
|
||||
itemName: '윤활유',
|
||||
specification: '비인정',
|
||||
unit: 'L',
|
||||
orderType: '외주발주',
|
||||
status: '사용',
|
||||
createdAt: '2026-01-04T09:00:00Z',
|
||||
updatedAt: '2026-01-04T09:00:00Z',
|
||||
},
|
||||
{
|
||||
id: '7',
|
||||
itemNumber: '123129',
|
||||
itemType: '소모품',
|
||||
categoryId: '3',
|
||||
categoryName: '공정자재',
|
||||
itemName: '포장재',
|
||||
specification: '인정',
|
||||
unit: 'BOX',
|
||||
orderType: '경품발주',
|
||||
status: '중지',
|
||||
createdAt: '2026-01-05T10:00:00Z',
|
||||
updatedAt: '2026-01-05T10:00:00Z',
|
||||
},
|
||||
];
|
||||
// ========================================
|
||||
// 타입 변환 함수
|
||||
// ========================================
|
||||
|
||||
// 품목 목록 조회
|
||||
/**
|
||||
* Backend item_type → Frontend itemType 변환
|
||||
* FG → 제품, PT → 부품, SM → 소모품, CS → 소모품, RM → 공과
|
||||
*/
|
||||
function transformItemType(backendType: string | null | undefined): ItemType {
|
||||
const typeMap: Record<string, ItemType> = {
|
||||
FG: '제품',
|
||||
PT: '부품',
|
||||
SM: '소모품',
|
||||
CS: '소모품',
|
||||
RM: '공과',
|
||||
};
|
||||
return typeMap[backendType?.toUpperCase() || ''] || '제품';
|
||||
}
|
||||
|
||||
/**
|
||||
* Frontend itemType → Backend item_type 변환
|
||||
* 제품 → FG, 부품 → PT, 소모품 → CS, 공과 → RM
|
||||
*/
|
||||
function transformToBackendItemType(frontendType: ItemType): string {
|
||||
const typeMap: Record<ItemType, string> = {
|
||||
'제품': 'FG',
|
||||
'부품': 'PT',
|
||||
'소모품': 'CS',
|
||||
'공과': 'RM',
|
||||
};
|
||||
return typeMap[frontendType] || 'FG';
|
||||
}
|
||||
|
||||
/**
|
||||
* Backend options → Frontend specification 변환
|
||||
*/
|
||||
function transformSpecification(options: Record<string, unknown> | null | undefined): Specification {
|
||||
const spec = options?.specification;
|
||||
if (spec === '인정' || spec === '비인정') return spec;
|
||||
return '인정'; // 기본값
|
||||
}
|
||||
|
||||
/**
|
||||
* Backend options → Frontend orderType 변환
|
||||
*/
|
||||
function transformOrderType(options: Record<string, unknown> | null | undefined): FrontOrderType {
|
||||
const orderType = options?.orderType as string | undefined;
|
||||
const validTypes: FrontOrderType[] = ['외주발주', '경품발주', '원자재발주'];
|
||||
if (orderType && validTypes.includes(orderType as FrontOrderType)) {
|
||||
return orderType as FrontOrderType;
|
||||
}
|
||||
return '외주발주'; // 기본값
|
||||
}
|
||||
|
||||
/**
|
||||
* Backend is_active + options → Frontend status 변환
|
||||
*/
|
||||
function transformStatus(isActive: boolean | null | undefined, options: Record<string, unknown> | null | undefined): ItemStatus {
|
||||
const status = options?.status as string | undefined;
|
||||
if (status === '승인' || status === '작업' || status === '사용' || status === '중지') {
|
||||
return status;
|
||||
}
|
||||
return isActive ? '사용' : '중지';
|
||||
}
|
||||
|
||||
/**
|
||||
* Backend options → Frontend orderItems 변환
|
||||
*/
|
||||
function transformOrderItems(options: Record<string, unknown> | null | undefined): OrderItem[] {
|
||||
const orderItems = options?.orderItems;
|
||||
if (Array.isArray(orderItems)) {
|
||||
return orderItems.map((item: { id?: string; label?: string; value?: string }, index: number) => ({
|
||||
id: item.id || `oi_${index}`,
|
||||
label: item.label || '',
|
||||
value: item.value || '',
|
||||
}));
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* API 응답 → Item 타입 변환
|
||||
*/
|
||||
interface ApiItem {
|
||||
id: number;
|
||||
code: string;
|
||||
name: string;
|
||||
item_type: string | null;
|
||||
category_id: number | null;
|
||||
category?: { name?: string } | null;
|
||||
unit: string | null;
|
||||
options: Record<string, unknown> | null;
|
||||
is_active: boolean;
|
||||
description: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
function transformItem(apiItem: ApiItem): Item {
|
||||
return {
|
||||
id: String(apiItem.id),
|
||||
itemNumber: apiItem.code || '',
|
||||
itemName: apiItem.name || '',
|
||||
itemType: transformItemType(apiItem.item_type),
|
||||
categoryId: apiItem.category_id ? String(apiItem.category_id) : '',
|
||||
categoryName: apiItem.category?.name || '',
|
||||
unit: apiItem.unit || 'EA',
|
||||
specification: transformSpecification(apiItem.options),
|
||||
orderType: transformOrderType(apiItem.options),
|
||||
status: transformStatus(apiItem.is_active, apiItem.options),
|
||||
createdAt: apiItem.created_at,
|
||||
updatedAt: apiItem.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* API 응답 → ItemDetail 타입 변환
|
||||
*/
|
||||
function transformItemDetail(apiItem: ApiItem): ItemDetail {
|
||||
return {
|
||||
...transformItem(apiItem),
|
||||
note: apiItem.description || '',
|
||||
orderItems: transformOrderItems(apiItem.options),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* ItemFormData → API 요청 데이터 변환
|
||||
*/
|
||||
function transformItemToApi(data: ItemFormData): Record<string, unknown> {
|
||||
return {
|
||||
code: data.itemNumber,
|
||||
name: data.itemName,
|
||||
item_type: transformToBackendItemType(data.itemType),
|
||||
category_id: data.categoryId ? parseInt(data.categoryId, 10) : null,
|
||||
unit: data.unit,
|
||||
is_active: data.status === '사용' || data.status === '승인',
|
||||
description: data.note || null,
|
||||
options: {
|
||||
specification: data.specification,
|
||||
orderType: data.orderType,
|
||||
status: data.status,
|
||||
orderItems: data.orderItems,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// API 함수
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* 품목 목록 조회
|
||||
* GET /api/v1/items
|
||||
*/
|
||||
export async function getItemList(
|
||||
params: ItemListParams = {}
|
||||
): Promise<{ success: boolean; data?: ItemListResponse; error?: string }> {
|
||||
try {
|
||||
// 시뮬레이션 딜레이
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
const queryParams: Record<string, string> = {};
|
||||
|
||||
let filteredItems = [...mockItems];
|
||||
// 페이지네이션
|
||||
if (params.page) queryParams.page = String(params.page);
|
||||
if (params.size) queryParams.size = String(params.size);
|
||||
|
||||
// 물품유형 필터
|
||||
// 검색
|
||||
if (params.search) queryParams.q = params.search;
|
||||
|
||||
// 품목유형 필터 (Frontend → Backend 변환)
|
||||
if (params.itemType && params.itemType !== 'all') {
|
||||
filteredItems = filteredItems.filter((item) => item.itemType === params.itemType);
|
||||
queryParams.type = transformToBackendItemType(params.itemType as ItemType);
|
||||
}
|
||||
|
||||
// 카테고리 필터
|
||||
if (params.categoryId && params.categoryId !== 'all') {
|
||||
filteredItems = filteredItems.filter((item) => item.categoryId === params.categoryId);
|
||||
queryParams.category_id = params.categoryId;
|
||||
}
|
||||
|
||||
// 규격 필터
|
||||
if (params.specification && params.specification !== 'all') {
|
||||
filteredItems = filteredItems.filter((item) => item.specification === params.specification);
|
||||
}
|
||||
|
||||
// 구분 필터
|
||||
if (params.orderType && params.orderType !== 'all') {
|
||||
filteredItems = filteredItems.filter((item) => item.orderType === params.orderType);
|
||||
}
|
||||
|
||||
// 상태 필터
|
||||
// 활성 상태 필터
|
||||
if (params.status && params.status !== 'all') {
|
||||
filteredItems = filteredItems.filter((item) => item.status === params.status);
|
||||
queryParams.active = params.status === '사용' || params.status === '승인' ? '1' : '0';
|
||||
}
|
||||
|
||||
// 검색어 필터
|
||||
if (params.search) {
|
||||
const search = params.search.toLowerCase();
|
||||
filteredItems = filteredItems.filter(
|
||||
(item) =>
|
||||
item.itemNumber.toLowerCase().includes(search) ||
|
||||
item.itemName.toLowerCase().includes(search) ||
|
||||
item.categoryName.toLowerCase().includes(search)
|
||||
);
|
||||
const response = await apiClient.get<{
|
||||
data: ApiItem[];
|
||||
meta?: { total: number; current_page: number; per_page: number };
|
||||
total?: number;
|
||||
current_page?: number;
|
||||
per_page?: number;
|
||||
}>('/items', { params: queryParams });
|
||||
|
||||
// API 응답 구조 처리 (data 배열 또는 페이지네이션 객체)
|
||||
const items = Array.isArray(response.data) ? response.data : (response.data as unknown as ApiItem[]);
|
||||
const meta = response.meta || {
|
||||
total: response.total || items.length,
|
||||
current_page: response.current_page || params.page || 1,
|
||||
per_page: response.per_page || params.size || 20,
|
||||
};
|
||||
|
||||
// Frontend 필터링 (Backend에서 지원하지 않는 필터)
|
||||
let transformedItems = items.map(transformItem);
|
||||
|
||||
// 규격 필터 (Frontend)
|
||||
if (params.specification && params.specification !== 'all') {
|
||||
transformedItems = transformedItems.filter((item) => item.specification === params.specification);
|
||||
}
|
||||
|
||||
// 날짜 필터
|
||||
// 구분 필터 (Frontend)
|
||||
if (params.orderType && params.orderType !== 'all') {
|
||||
transformedItems = transformedItems.filter((item) => item.orderType === params.orderType);
|
||||
}
|
||||
|
||||
// 상태 필터 (Frontend에서 추가 처리)
|
||||
if (params.status && params.status !== 'all') {
|
||||
transformedItems = transformedItems.filter((item) => item.status === params.status);
|
||||
}
|
||||
|
||||
// 날짜 필터 (Frontend)
|
||||
if (params.startDate) {
|
||||
const startDate = new Date(params.startDate);
|
||||
filteredItems = filteredItems.filter((item) => new Date(item.createdAt) >= startDate);
|
||||
transformedItems = transformedItems.filter((item) => new Date(item.createdAt) >= startDate);
|
||||
}
|
||||
if (params.endDate) {
|
||||
const endDate = new Date(params.endDate);
|
||||
endDate.setHours(23, 59, 59, 999);
|
||||
filteredItems = filteredItems.filter((item) => new Date(item.createdAt) <= endDate);
|
||||
transformedItems = transformedItems.filter((item) => new Date(item.createdAt) <= endDate);
|
||||
}
|
||||
|
||||
// 정렬
|
||||
// 정렬 (Frontend)
|
||||
if (params.sortBy === 'oldest') {
|
||||
filteredItems.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
|
||||
transformedItems.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
|
||||
} else {
|
||||
// 기본: 최신순
|
||||
filteredItems.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
||||
transformedItems.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
||||
}
|
||||
|
||||
// 페이지네이션
|
||||
const page = params.page || 1;
|
||||
const size = params.size || 20;
|
||||
const start = (page - 1) * size;
|
||||
const paginatedItems = filteredItems.slice(start, start + size);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
items: paginatedItems,
|
||||
total: filteredItems.length,
|
||||
page,
|
||||
size,
|
||||
items: transformedItems,
|
||||
total: meta.total,
|
||||
page: meta.current_page,
|
||||
size: meta.per_page,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
@@ -190,17 +253,20 @@ export async function getItemList(
|
||||
}
|
||||
}
|
||||
|
||||
// 품목 통계 조회
|
||||
/**
|
||||
* 품목 통계 조회
|
||||
* GET /api/v1/items/stats
|
||||
*/
|
||||
export async function getItemStats(): Promise<{ success: boolean; data?: ItemStats; error?: string }> {
|
||||
try {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
const total = mockItems.length;
|
||||
const active = mockItems.filter((item) => item.status === '사용' || item.status === '승인').length;
|
||||
const response = await apiClient.get<{ total: number; active: number }>('/items/stats');
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: { total, active },
|
||||
data: {
|
||||
total: response.total,
|
||||
active: response.active,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('품목 통계 조회 오류:', error);
|
||||
@@ -208,17 +274,13 @@ export async function getItemStats(): Promise<{ success: boolean; data?: ItemSta
|
||||
}
|
||||
}
|
||||
|
||||
// 품목 삭제
|
||||
/**
|
||||
* 품목 삭제
|
||||
* DELETE /api/v1/items/{id}
|
||||
*/
|
||||
export async function deleteItem(id: string): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
|
||||
// 실제 구현에서는 API 호출
|
||||
const index = mockItems.findIndex((item) => item.id === id);
|
||||
if (index !== -1) {
|
||||
mockItems.splice(index, 1);
|
||||
}
|
||||
|
||||
await apiClient.delete(`/items/${id}`);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('품목 삭제 오류:', error);
|
||||
@@ -226,40 +288,43 @@ export async function deleteItem(id: string): Promise<{ success: boolean; error?
|
||||
}
|
||||
}
|
||||
|
||||
// 품목 일괄 삭제
|
||||
/**
|
||||
* 품목 일괄 삭제
|
||||
* DELETE /api/v1/items/batch
|
||||
*/
|
||||
export async function deleteItems(
|
||||
ids: string[]
|
||||
): Promise<{ success: boolean; deletedCount?: number; error?: string }> {
|
||||
try {
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
|
||||
let deletedCount = 0;
|
||||
ids.forEach((id) => {
|
||||
const index = mockItems.findIndex((item) => item.id === id);
|
||||
if (index !== -1) {
|
||||
mockItems.splice(index, 1);
|
||||
deletedCount++;
|
||||
}
|
||||
const response = await apiClient.delete<{ deleted_count: number }>('/items/batch', {
|
||||
data: { ids: ids.map((id) => parseInt(id, 10)) },
|
||||
});
|
||||
|
||||
return { success: true, deletedCount };
|
||||
return { success: true, deletedCount: response.deleted_count };
|
||||
} catch (error) {
|
||||
console.error('품목 일괄 삭제 오류:', error);
|
||||
return { success: false, error: '품목 일괄 삭제에 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// 카테고리 목록 조회 (필터용)
|
||||
/**
|
||||
* 카테고리 목록 조회 (필터용)
|
||||
* GET /api/v1/categories
|
||||
*/
|
||||
export async function getCategoryOptions(): Promise<{
|
||||
success: boolean;
|
||||
data?: { id: string; name: string }[];
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
const response = await apiClient.get<{
|
||||
data: { id: number; name: string }[];
|
||||
}>('/categories', { params: { size: 100 } });
|
||||
|
||||
// 유니크한 카테고리 추출
|
||||
const categories = [...new Map(mockItems.map((item) => [item.categoryId, { id: item.categoryId, name: item.categoryName }])).values()];
|
||||
const categories = response.data.map((cat) => ({
|
||||
id: String(cat.id),
|
||||
name: cat.name,
|
||||
}));
|
||||
|
||||
return { success: true, data: categories };
|
||||
} catch (error) {
|
||||
@@ -268,93 +333,49 @@ export async function getCategoryOptions(): Promise<{
|
||||
}
|
||||
}
|
||||
|
||||
// 발주 항목 목데이터
|
||||
const mockOrderItems: Record<string, OrderItem[]> = {
|
||||
'1': [
|
||||
{ id: 'oi1', label: '무게', value: '400KG' },
|
||||
{ id: 'oi2', label: '무게', value: '500KG' },
|
||||
],
|
||||
'2': [
|
||||
{ id: 'oi3', label: '전압', value: '220V' },
|
||||
],
|
||||
'3': [],
|
||||
'4': [
|
||||
{ id: 'oi4', label: '규격', value: 'M10x20' },
|
||||
],
|
||||
'5': [],
|
||||
'6': [],
|
||||
'7': [],
|
||||
};
|
||||
|
||||
// 품목 상세 조회
|
||||
/**
|
||||
* 품목 상세 조회
|
||||
* GET /api/v1/items/{id}
|
||||
*/
|
||||
export async function getItem(id: string): Promise<{
|
||||
success: boolean;
|
||||
data?: ItemDetail;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
const response = await apiClient.get<ApiItem>(`/items/${id}`);
|
||||
|
||||
const item = mockItems.find((i) => i.id === id);
|
||||
if (!item) {
|
||||
return { success: false, error: '품목을 찾을 수 없습니다.' };
|
||||
}
|
||||
|
||||
const itemDetail: ItemDetail = {
|
||||
...item,
|
||||
note: '',
|
||||
orderItems: mockOrderItems[id] || [],
|
||||
};
|
||||
|
||||
return { success: true, data: itemDetail };
|
||||
return { success: true, data: transformItemDetail(response) };
|
||||
} catch (error) {
|
||||
console.error('품목 상세 조회 오류:', error);
|
||||
return { success: false, error: '품목 정보를 불러오는데 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// 품목 등록
|
||||
/**
|
||||
* 품목 등록
|
||||
* POST /api/v1/items
|
||||
*/
|
||||
export async function createItem(data: ItemFormData): Promise<{
|
||||
success: boolean;
|
||||
data?: { id: string };
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
const apiData = transformItemToApi(data);
|
||||
const response = await apiClient.post<{ id: number }>('/items', apiData);
|
||||
|
||||
// 새 ID 생성
|
||||
const newId = String(Math.max(...mockItems.map((i) => parseInt(i.id))) + 1);
|
||||
|
||||
// 카테고리명 찾기
|
||||
const category = mockItems.find((i) => i.categoryId === data.categoryId);
|
||||
const categoryName = category?.categoryName || '기본';
|
||||
|
||||
const newItem: Item = {
|
||||
id: newId,
|
||||
itemNumber: data.itemNumber,
|
||||
itemType: data.itemType,
|
||||
categoryId: data.categoryId,
|
||||
categoryName,
|
||||
itemName: data.itemName,
|
||||
specification: data.specification,
|
||||
unit: data.unit,
|
||||
orderType: data.orderType,
|
||||
status: data.status,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
mockItems.push(newItem);
|
||||
mockOrderItems[newId] = data.orderItems;
|
||||
|
||||
return { success: true, data: { id: newId } };
|
||||
return { success: true, data: { id: String(response.id) } };
|
||||
} catch (error) {
|
||||
console.error('품목 등록 오류:', error);
|
||||
return { success: false, error: '품목 등록에 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// 품목 수정
|
||||
/**
|
||||
* 품목 수정
|
||||
* PUT /api/v1/items/{id}
|
||||
*/
|
||||
export async function updateItem(
|
||||
id: string,
|
||||
data: ItemFormData
|
||||
@@ -363,32 +384,8 @@ export async function updateItem(
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
|
||||
const index = mockItems.findIndex((i) => i.id === id);
|
||||
if (index === -1) {
|
||||
return { success: false, error: '품목을 찾을 수 없습니다.' };
|
||||
}
|
||||
|
||||
// 카테고리명 찾기
|
||||
const category = mockItems.find((i) => i.categoryId === data.categoryId);
|
||||
const categoryName = category?.categoryName || mockItems[index].categoryName;
|
||||
|
||||
mockItems[index] = {
|
||||
...mockItems[index],
|
||||
itemNumber: data.itemNumber,
|
||||
itemType: data.itemType,
|
||||
categoryId: data.categoryId,
|
||||
categoryName,
|
||||
itemName: data.itemName,
|
||||
specification: data.specification,
|
||||
unit: data.unit,
|
||||
orderType: data.orderType,
|
||||
status: data.status,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
mockOrderItems[id] = data.orderItems;
|
||||
const apiData = transformItemToApi(data);
|
||||
await apiClient.put(`/items/${id}`, apiData);
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,89 +1,85 @@
|
||||
'use server';
|
||||
|
||||
import type { Labor, LaborListParams, LaborFormData, LaborStats } from './types';
|
||||
import type {
|
||||
Labor,
|
||||
LaborListParams,
|
||||
LaborFormData,
|
||||
LaborStats,
|
||||
} from './types';
|
||||
import { apiClient } from '@/lib/api';
|
||||
|
||||
// 목데이터 - 7건
|
||||
const mockLabors: Labor[] = [
|
||||
{
|
||||
id: '1',
|
||||
laborNumber: '123123',
|
||||
category: '가로',
|
||||
minM: 0,
|
||||
maxM: 6.00,
|
||||
laborPrice: 400000,
|
||||
status: '사용',
|
||||
createdAt: '2026-01-03T10:00:00Z',
|
||||
updatedAt: '2026-01-03T10:00:00Z',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
laborNumber: '123123',
|
||||
category: '세로할증',
|
||||
minM: 3.50,
|
||||
maxM: 3.00,
|
||||
laborPrice: null,
|
||||
status: '중지',
|
||||
createdAt: '2026-01-03T09:00:00Z',
|
||||
updatedAt: '2026-01-03T09:00:00Z',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
laborNumber: '123123',
|
||||
category: '가로',
|
||||
minM: 6.01,
|
||||
maxM: 7.00,
|
||||
laborPrice: null,
|
||||
status: '사용',
|
||||
createdAt: '2026-01-02T15:00:00Z',
|
||||
updatedAt: '2026-01-02T15:00:00Z',
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
laborNumber: '123123',
|
||||
category: '세로할증',
|
||||
minM: 3.51,
|
||||
maxM: 4.50,
|
||||
laborPrice: 50000,
|
||||
status: '사용',
|
||||
createdAt: '2026-01-02T14:00:00Z',
|
||||
updatedAt: '2026-01-02T14:00:00Z',
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
laborNumber: '123123',
|
||||
category: '가로',
|
||||
minM: 0,
|
||||
maxM: 6.00,
|
||||
laborPrice: null,
|
||||
status: '사용',
|
||||
createdAt: '2026-01-01T12:00:00Z',
|
||||
updatedAt: '2026-01-01T12:00:00Z',
|
||||
},
|
||||
{
|
||||
id: '6',
|
||||
laborNumber: '123123',
|
||||
category: '세로할증',
|
||||
minM: 3.50,
|
||||
maxM: 0,
|
||||
laborPrice: 50000,
|
||||
status: '사용',
|
||||
createdAt: '2026-01-01T11:00:00Z',
|
||||
updatedAt: '2026-01-01T11:00:00Z',
|
||||
},
|
||||
{
|
||||
id: '7',
|
||||
laborNumber: '123123',
|
||||
category: '가로',
|
||||
minM: 0,
|
||||
maxM: 0,
|
||||
laborPrice: null,
|
||||
status: '중지',
|
||||
createdAt: '2026-01-01T10:00:00Z',
|
||||
updatedAt: '2026-01-01T10:00:00Z',
|
||||
},
|
||||
];
|
||||
/**
|
||||
* 시공관리 - 노임관리 Server Actions
|
||||
* 표준화된 apiClient 사용 버전
|
||||
*/
|
||||
|
||||
// 노임 목록 조회
|
||||
// ========================================
|
||||
// API 응답 타입
|
||||
// ========================================
|
||||
|
||||
interface ApiLabor {
|
||||
id: number;
|
||||
labor_number: string;
|
||||
category: '가로' | '세로할증';
|
||||
min_m: number;
|
||||
max_m: number;
|
||||
labor_price: number | null;
|
||||
status: '사용' | '중지';
|
||||
is_active: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
interface ApiLaborStats {
|
||||
total: number;
|
||||
active: number;
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 타입 변환 함수
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* API 응답 → Labor 타입 변환
|
||||
*/
|
||||
function transformLabor(apiData: ApiLabor): Labor {
|
||||
return {
|
||||
id: String(apiData.id),
|
||||
laborNumber: apiData.labor_number || '',
|
||||
category: apiData.category || '가로',
|
||||
minM: apiData.min_m || 0,
|
||||
maxM: apiData.max_m || 0,
|
||||
laborPrice: apiData.labor_price,
|
||||
status: apiData.status || '사용',
|
||||
createdAt: apiData.created_at || '',
|
||||
updatedAt: apiData.updated_at || '',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* LaborFormData → API 요청 데이터 변환
|
||||
*/
|
||||
function transformToApiRequest(data: Partial<LaborFormData>): Record<string, unknown> {
|
||||
const apiData: Record<string, unknown> = {};
|
||||
|
||||
if (data.laborNumber !== undefined) apiData.labor_number = data.laborNumber;
|
||||
if (data.category !== undefined) apiData.category = data.category;
|
||||
if (data.minM !== undefined) apiData.min_m = data.minM;
|
||||
if (data.maxM !== undefined) apiData.max_m = data.maxM;
|
||||
if (data.laborPrice !== undefined) apiData.labor_price = data.laborPrice;
|
||||
if (data.status !== undefined) apiData.status = data.status;
|
||||
|
||||
return apiData;
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// API 함수
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* 노임 목록 조회
|
||||
* GET /api/v1/labor
|
||||
*/
|
||||
export async function getLaborList(params: LaborListParams = {}): Promise<{
|
||||
success: boolean;
|
||||
data?: Labor[];
|
||||
@@ -91,125 +87,120 @@ export async function getLaborList(params: LaborListParams = {}): Promise<{
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
let filtered = [...mockLabors];
|
||||
const queryParams: Record<string, string> = {};
|
||||
|
||||
// 검색어 필터
|
||||
if (params.search) {
|
||||
const searchLower = params.search.toLowerCase();
|
||||
filtered = filtered.filter(
|
||||
(labor) =>
|
||||
labor.laborNumber.toLowerCase().includes(searchLower) ||
|
||||
labor.category.toLowerCase().includes(searchLower)
|
||||
);
|
||||
}
|
||||
// 검색
|
||||
if (params.search) queryParams.search = params.search;
|
||||
|
||||
// 구분 필터
|
||||
if (params.category && params.category !== 'all') {
|
||||
filtered = filtered.filter((labor) => labor.category === params.category);
|
||||
}
|
||||
|
||||
// 상태 필터
|
||||
if (params.status && params.status !== 'all') {
|
||||
filtered = filtered.filter((labor) => labor.status === params.status);
|
||||
}
|
||||
// 필터
|
||||
if (params.category && params.category !== 'all') queryParams.category = params.category;
|
||||
if (params.status && params.status !== 'all') queryParams.status = params.status;
|
||||
|
||||
// 날짜 필터
|
||||
if (params.startDate) {
|
||||
filtered = filtered.filter(
|
||||
(labor) => new Date(labor.createdAt) >= new Date(params.startDate!)
|
||||
);
|
||||
}
|
||||
if (params.endDate) {
|
||||
const endDate = new Date(params.endDate);
|
||||
endDate.setHours(23, 59, 59, 999);
|
||||
filtered = filtered.filter(
|
||||
(labor) => new Date(labor.createdAt) <= endDate
|
||||
);
|
||||
}
|
||||
if (params.startDate) queryParams.start_date = params.startDate;
|
||||
if (params.endDate) queryParams.end_date = params.endDate;
|
||||
|
||||
// 페이지네이션
|
||||
if (params.page) queryParams.page = String(params.page);
|
||||
if (params.limit) queryParams.per_page = String(params.limit);
|
||||
|
||||
// 정렬
|
||||
if (params.sortOrder === '등록순') {
|
||||
filtered.sort(
|
||||
(a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
|
||||
);
|
||||
queryParams.sort_by = 'created_at';
|
||||
queryParams.sort_dir = 'asc';
|
||||
} else {
|
||||
// 기본: 최신순
|
||||
filtered.sort(
|
||||
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
|
||||
);
|
||||
queryParams.sort_by = 'created_at';
|
||||
queryParams.sort_dir = 'desc';
|
||||
}
|
||||
|
||||
const total = filtered.length;
|
||||
const response = await apiClient.get<{
|
||||
data: ApiLabor[];
|
||||
current_page: number;
|
||||
per_page: number;
|
||||
total: number;
|
||||
last_page: number;
|
||||
}>('/labor', { params: queryParams });
|
||||
|
||||
// 페이지네이션
|
||||
if (params.page && params.limit) {
|
||||
const start = (params.page - 1) * params.limit;
|
||||
filtered = filtered.slice(start, start + params.limit);
|
||||
}
|
||||
const items = (response.data || []).map(transformLabor);
|
||||
|
||||
return { success: true, data: filtered, total };
|
||||
return {
|
||||
success: true,
|
||||
data: items,
|
||||
total: response.total || 0,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('노임 목록 조회 실패:', error);
|
||||
return { success: false, error: '노임 목록을 불러오는데 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// 노임 통계 조회
|
||||
/**
|
||||
* 노임 통계 조회
|
||||
* GET /api/v1/labor/stats
|
||||
*/
|
||||
export async function getLaborStats(): Promise<{
|
||||
success: boolean;
|
||||
data?: LaborStats;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
const total = mockLabors.length;
|
||||
const active = mockLabors.filter((labor) => labor.status === '사용').length;
|
||||
return { success: true, data: { total, active } };
|
||||
const response = await apiClient.get<ApiLaborStats>('/labor/stats');
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
total: response.total || 0,
|
||||
active: response.active || 0,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('노임 통계 조회 실패:', error);
|
||||
return { success: false, error: '노임 통계를 불러오는데 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// 노임 상세 조회
|
||||
/**
|
||||
* 노임 상세 조회
|
||||
* GET /api/v1/labor/{id}
|
||||
*/
|
||||
export async function getLabor(id: string): Promise<{
|
||||
success: boolean;
|
||||
data?: Labor;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
const labor = mockLabors.find((l) => l.id === id);
|
||||
if (!labor) {
|
||||
return { success: false, error: '노임 정보를 찾을 수 없습니다.' };
|
||||
}
|
||||
return { success: true, data: labor };
|
||||
const response = await apiClient.get<ApiLabor>(`/labor/${id}`);
|
||||
return { success: true, data: transformLabor(response) };
|
||||
} catch (error) {
|
||||
console.error('노임 상세 조회 실패:', error);
|
||||
return { success: false, error: '노임 정보를 불러오는데 실패했습니다.' };
|
||||
return { success: false, error: '노임 정보를 찾을 수 없습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// 노임 등록
|
||||
/**
|
||||
* 노임 등록
|
||||
* POST /api/v1/labor
|
||||
*/
|
||||
export async function createLabor(data: LaborFormData): Promise<{
|
||||
success: boolean;
|
||||
data?: Labor;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
const newLabor: Labor = {
|
||||
id: String(Date.now()),
|
||||
...data,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
mockLabors.unshift(newLabor);
|
||||
return { success: true, data: newLabor };
|
||||
const apiData = transformToApiRequest(data);
|
||||
const response = await apiClient.post<ApiLabor>('/labor', apiData);
|
||||
return { success: true, data: transformLabor(response) };
|
||||
} catch (error) {
|
||||
console.error('노임 등록 실패:', error);
|
||||
return { success: false, error: '노임 등록에 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// 노임 수정
|
||||
/**
|
||||
* 노임 수정
|
||||
* PUT /api/v1/labor/{id}
|
||||
*/
|
||||
export async function updateLabor(
|
||||
id: string,
|
||||
data: LaborFormData
|
||||
@@ -219,33 +210,25 @@ export async function updateLabor(
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
const index = mockLabors.findIndex((l) => l.id === id);
|
||||
if (index === -1) {
|
||||
return { success: false, error: '노임 정보를 찾을 수 없습니다.' };
|
||||
}
|
||||
mockLabors[index] = {
|
||||
...mockLabors[index],
|
||||
...data,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
return { success: true, data: mockLabors[index] };
|
||||
const apiData = transformToApiRequest(data);
|
||||
const response = await apiClient.put<ApiLabor>(`/labor/${id}`, apiData);
|
||||
return { success: true, data: transformLabor(response) };
|
||||
} catch (error) {
|
||||
console.error('노임 수정 실패:', error);
|
||||
return { success: false, error: '노임 수정에 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// 노임 삭제
|
||||
/**
|
||||
* 노임 삭제
|
||||
* DELETE /api/v1/labor/{id}
|
||||
*/
|
||||
export async function deleteLabor(id: string): Promise<{
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
const index = mockLabors.findIndex((l) => l.id === id);
|
||||
if (index === -1) {
|
||||
return { success: false, error: '노임 정보를 찾을 수 없습니다.' };
|
||||
}
|
||||
mockLabors.splice(index, 1);
|
||||
await apiClient.delete(`/labor/${id}`);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('노임 삭제 실패:', error);
|
||||
@@ -253,22 +236,20 @@ export async function deleteLabor(id: string): Promise<{
|
||||
}
|
||||
}
|
||||
|
||||
// 노임 일괄 삭제
|
||||
/**
|
||||
* 노임 일괄 삭제
|
||||
* DELETE /api/v1/labor/bulk
|
||||
*/
|
||||
export async function deleteLaborBulk(ids: string[]): Promise<{
|
||||
success: boolean;
|
||||
deletedCount?: number;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
let deletedCount = 0;
|
||||
for (const id of ids) {
|
||||
const index = mockLabors.findIndex((l) => l.id === id);
|
||||
if (index !== -1) {
|
||||
mockLabors.splice(index, 1);
|
||||
deletedCount++;
|
||||
}
|
||||
}
|
||||
return { success: true, deletedCount };
|
||||
await apiClient.delete('/labor/bulk', {
|
||||
data: { ids: ids.map((id) => Number(id)) },
|
||||
});
|
||||
return { success: true, deletedCount: ids.length };
|
||||
} catch (error) {
|
||||
console.error('노임 일괄 삭제 실패:', error);
|
||||
return { success: false, error: '노임 일괄 삭제에 실패했습니다.' };
|
||||
|
||||
@@ -1,131 +1,189 @@
|
||||
'use server';
|
||||
|
||||
import type { Order, OrderStats, OrderType, OrderDetail, OrderDetailFormData } from './types';
|
||||
import { MOCK_ORDER_DETAIL } from './types';
|
||||
import { format, addDays, subDays, subMonths } from 'date-fns';
|
||||
import type { Order, OrderStats, OrderDetail, OrderDetailFormData, OrderStatus, OrderType } from './types';
|
||||
import { apiClient, getOrderStatusOptions, getOrderTypeOptions } from '@/lib/api';
|
||||
|
||||
// ========================================
|
||||
// 타입 변환 함수
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* 목업 발주 데이터 생성 (고정 데이터)
|
||||
* - types.ts의 MOCK 옵션들과 정확히 일치해야 필터가 동작함
|
||||
* - Math.random() 제거 → index 기반 deterministic 데이터
|
||||
* Backend status_code → Frontend OrderStatus 변환
|
||||
* DRAFT → waiting, CONFIRMED → order_complete, IN_PROGRESS → delivery_scheduled, COMPLETED → delivery_complete
|
||||
*/
|
||||
function generateMockOrders(): Order[] {
|
||||
// types.ts MOCK_PARTNERS와 일치
|
||||
const partners = [
|
||||
{ id: '1', name: '(주)대한건설' },
|
||||
{ id: '2', name: '삼성물산' },
|
||||
{ id: '3', name: '현대건설' },
|
||||
{ id: '4', name: 'GS건설' },
|
||||
{ id: '5', name: '대림산업' },
|
||||
];
|
||||
|
||||
// types.ts MOCK_SITES와 일치
|
||||
const sites = [
|
||||
'강남 오피스빌딩 신축',
|
||||
'판교 데이터센터',
|
||||
'송도 물류센터',
|
||||
'인천공항 터미널',
|
||||
'부산항 창고',
|
||||
];
|
||||
|
||||
const names = [
|
||||
'철근 HD13',
|
||||
'철근 HD16',
|
||||
'철근 HD19',
|
||||
'철근 HD22',
|
||||
'H빔 300x300',
|
||||
'H빔 200x200',
|
||||
'콘크리트 25-21-12',
|
||||
'레미콘 배합',
|
||||
];
|
||||
|
||||
const items = [
|
||||
'철근 HD13',
|
||||
'철근 HD16',
|
||||
'철근 HD19',
|
||||
'H빔',
|
||||
'레미콘',
|
||||
'앵커볼트',
|
||||
'데크플레이트',
|
||||
'용접봉',
|
||||
];
|
||||
|
||||
// types.ts MOCK_CONSTRUCTION_PM과 일치
|
||||
const constructionPMs = ['홍길동', '김철수', '이영희', '박민수'];
|
||||
// types.ts MOCK_ORDER_MANAGERS와 일치
|
||||
const orderManagers = ['김담당', '이담당', '박담당', '최담당'];
|
||||
// types.ts MOCK_ORDER_COMPANIES와 일치
|
||||
const orderCompanies = ['A건설', 'B철강', 'C자재', 'D산업'];
|
||||
// types.ts MOCK_WORK_TEAM_LEADERS와 일치
|
||||
const workTeamLeaders = ['이반장', '김반장', '박반장', '최반장'];
|
||||
const orderTypes: OrderType[] = ['steel_bar', 'material', 'outsourcing'];
|
||||
const statuses: Order['status'][] = ['waiting', 'order_complete', 'delivery_scheduled', 'delivery_complete'];
|
||||
|
||||
const orders: Order[] = [];
|
||||
// 고정 기준일 (2026-01-06)
|
||||
const baseDate = new Date(2026, 0, 6);
|
||||
|
||||
for (let i = 0; i < 50; i++) {
|
||||
// index 기반 deterministic 선택 (랜덤 제거)
|
||||
const partner = partners[i % partners.length];
|
||||
const site = sites[i % sites.length];
|
||||
const status = statuses[i % statuses.length];
|
||||
const orderType = orderTypes[i % orderTypes.length];
|
||||
|
||||
// 날짜도 index 기반으로 고정
|
||||
const monthOffset = i % 3; // 0, 1, 2개월 전
|
||||
const dayOffset = (i * 3) % 30; // 0~29일 분산
|
||||
const periodStart = subMonths(addDays(baseDate, -dayOffset), monthOffset);
|
||||
const periodEnd = addDays(periodStart, 10 + (i % 20)); // 10~29일 기간
|
||||
const orderDate = subDays(periodStart, i % 5);
|
||||
const constructionStartDate = addDays(periodStart, i % 5);
|
||||
const plannedDelivery = addDays(orderDate, 3 + (i % 14));
|
||||
const actualDelivery = status === 'delivery_complete'
|
||||
? format(addDays(plannedDelivery, (i % 5) - 2), 'yyyy-MM-dd')
|
||||
: null;
|
||||
|
||||
orders.push({
|
||||
id: `order-${i + 1}`,
|
||||
contractNumber: `CT-${2026}-${String(i + 1).padStart(4, '0')}`,
|
||||
partnerId: partner.id,
|
||||
partnerName: partner.name,
|
||||
siteName: site,
|
||||
name: names[i % names.length],
|
||||
constructionPM: constructionPMs[i % constructionPMs.length],
|
||||
orderManager: orderManagers[i % orderManagers.length],
|
||||
orderNumber: `ORD-${2026}-${String(i + 1).padStart(4, '0')}`,
|
||||
orderCompany: orderCompanies[i % orderCompanies.length],
|
||||
workTeamLeader: workTeamLeaders[i % workTeamLeaders.length],
|
||||
constructionStartDate: format(constructionStartDate, 'yyyy-MM-dd'),
|
||||
orderType,
|
||||
item: items[i % items.length],
|
||||
quantity: 10 + (i * 7) % 90, // 10~99 고정 패턴
|
||||
orderDate: format(orderDate, 'yyyy-MM-dd'),
|
||||
plannedDeliveryDate: format(plannedDelivery, 'yyyy-MM-dd'),
|
||||
actualDeliveryDate: actualDelivery,
|
||||
status,
|
||||
periodStart: format(periodStart, 'yyyy-MM-dd'),
|
||||
periodEnd: format(periodEnd, 'yyyy-MM-dd'),
|
||||
createdAt: format(subDays(periodStart, i % 10), 'yyyy-MM-dd\'T\'HH:mm:ss'),
|
||||
updatedAt: format(baseDate, 'yyyy-MM-dd\'T\'HH:mm:ss'),
|
||||
});
|
||||
}
|
||||
|
||||
return orders;
|
||||
function transformStatus(backendStatus: string | null | undefined): OrderStatus {
|
||||
const statusMap: Record<string, OrderStatus> = {
|
||||
DRAFT: 'waiting',
|
||||
CONFIRMED: 'order_complete',
|
||||
IN_PROGRESS: 'delivery_scheduled',
|
||||
COMPLETED: 'delivery_complete',
|
||||
CANCELLED: 'waiting', // 취소는 대기로 표시
|
||||
};
|
||||
return statusMap[backendStatus?.toUpperCase() || ''] || 'waiting';
|
||||
}
|
||||
|
||||
// 캐시된 목업 데이터
|
||||
let cachedOrders: Order[] | null = null;
|
||||
|
||||
function getMockOrders(): Order[] {
|
||||
if (!cachedOrders) {
|
||||
cachedOrders = generateMockOrders();
|
||||
}
|
||||
return cachedOrders;
|
||||
/**
|
||||
* Frontend OrderStatus → Backend status_code 변환
|
||||
*/
|
||||
function transformToBackendStatus(frontendStatus: OrderStatus): string {
|
||||
const statusMap: Record<OrderStatus, string> = {
|
||||
waiting: 'DRAFT',
|
||||
order_complete: 'CONFIRMED',
|
||||
delivery_scheduled: 'IN_PROGRESS',
|
||||
delivery_complete: 'COMPLETED',
|
||||
};
|
||||
return statusMap[frontendStatus] || 'DRAFT';
|
||||
}
|
||||
|
||||
/**
|
||||
* Backend order_type_code → Frontend OrderType 변환
|
||||
*/
|
||||
function transformOrderType(backendType: string | null | undefined): OrderType {
|
||||
// Backend: ORDER, PURCHASE
|
||||
// Frontend: steel_bar, material, outsourcing
|
||||
// 현재 Backend는 ORDER/PURCHASE만 있으므로 options에서 가져오거나 기본값 사용
|
||||
const typeMap: Record<string, OrderType> = {
|
||||
ORDER: 'steel_bar',
|
||||
PURCHASE: 'material',
|
||||
};
|
||||
return typeMap[backendType?.toUpperCase() || ''] || 'steel_bar';
|
||||
}
|
||||
|
||||
/**
|
||||
* Frontend OrderType → Backend order_type_code 변환
|
||||
*/
|
||||
function transformToBackendOrderType(frontendType: OrderType): string {
|
||||
const typeMap: Record<OrderType, string> = {
|
||||
steel_bar: 'ORDER',
|
||||
material: 'PURCHASE',
|
||||
outsourcing: 'ORDER',
|
||||
};
|
||||
return typeMap[frontendType] || 'ORDER';
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// API 응답 타입
|
||||
// ========================================
|
||||
|
||||
interface ApiOrder {
|
||||
id: number;
|
||||
order_no: string;
|
||||
client_id: number | null;
|
||||
client_name: string | null;
|
||||
client?: { id: number; name: string } | null;
|
||||
site_name: string | null;
|
||||
status_code: string;
|
||||
order_type_code: string;
|
||||
received_at: string | null;
|
||||
delivery_date: string | null;
|
||||
actual_delivery_date: string | null;
|
||||
total_amount: number | null;
|
||||
supply_amount: number | null;
|
||||
tax_amount: number | null;
|
||||
memo: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
items?: ApiOrderItem[];
|
||||
quote?: { id: number; quote_no: string; site_name: string } | null;
|
||||
}
|
||||
|
||||
interface ApiOrderItem {
|
||||
id: number;
|
||||
item_id: number | null;
|
||||
item_name: string;
|
||||
specification: string | null;
|
||||
quantity: number;
|
||||
unit: string | null;
|
||||
unit_price: number;
|
||||
supply_amount: number;
|
||||
tax_amount: number;
|
||||
total_amount: number;
|
||||
sort_order: number;
|
||||
}
|
||||
|
||||
interface ApiOrderStats {
|
||||
total: number;
|
||||
draft: number;
|
||||
confirmed: number;
|
||||
in_progress: number;
|
||||
completed: number;
|
||||
cancelled: number;
|
||||
total_amount: number;
|
||||
confirmed_amount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* API 응답 → Order 타입 변환
|
||||
*/
|
||||
function transformOrder(apiOrder: ApiOrder): Order {
|
||||
return {
|
||||
id: String(apiOrder.id),
|
||||
contractNumber: apiOrder.quote?.quote_no || '',
|
||||
partnerId: apiOrder.client_id ? String(apiOrder.client_id) : '',
|
||||
partnerName: apiOrder.client?.name || apiOrder.client_name || '',
|
||||
siteName: apiOrder.site_name || '',
|
||||
name: apiOrder.items?.[0]?.item_name || '',
|
||||
constructionPM: '', // Backend에 없음 - options로 확장 가능
|
||||
orderManager: '', // Backend에 없음 - options로 확장 가능
|
||||
orderNumber: apiOrder.order_no,
|
||||
orderCompany: '', // Backend에 없음 - options로 확장 가능
|
||||
workTeamLeader: '', // Backend에 없음 - options로 확장 가능
|
||||
constructionStartDate: apiOrder.received_at || '',
|
||||
orderType: transformOrderType(apiOrder.order_type_code),
|
||||
item: apiOrder.items?.[0]?.item_name || '',
|
||||
quantity: apiOrder.items?.[0]?.quantity || 0,
|
||||
orderDate: apiOrder.received_at || '',
|
||||
plannedDeliveryDate: apiOrder.delivery_date || '',
|
||||
actualDeliveryDate: apiOrder.actual_delivery_date || null,
|
||||
status: transformStatus(apiOrder.status_code),
|
||||
periodStart: apiOrder.received_at || '',
|
||||
periodEnd: apiOrder.delivery_date || '',
|
||||
createdAt: apiOrder.created_at,
|
||||
updatedAt: apiOrder.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* API 응답 → OrderDetail 타입 변환
|
||||
*/
|
||||
function transformOrderDetail(apiOrder: ApiOrder): OrderDetail {
|
||||
const baseOrder = transformOrder(apiOrder);
|
||||
|
||||
return {
|
||||
...baseOrder,
|
||||
orderCompanyId: '', // Backend에 없음
|
||||
deliveryLocationType: 'site',
|
||||
deliveryAddress: '', // Backend에 없음
|
||||
deliveryMemo: apiOrder.memo || '',
|
||||
totalAmount: apiOrder.total_amount || 0,
|
||||
supplyAmount: apiOrder.supply_amount || 0,
|
||||
taxAmount: apiOrder.tax_amount || 0,
|
||||
categories: [], // Backend 구조와 다름 - items를 카테고리로 그룹화 필요
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* OrderDetailFormData → API 요청 데이터 변환
|
||||
*/
|
||||
function transformOrderToApi(data: OrderDetailFormData): Record<string, unknown> {
|
||||
return {
|
||||
client_id: data.partnerId ? parseInt(data.partnerId, 10) : null,
|
||||
site_name: data.siteName,
|
||||
status_code: transformToBackendStatus(data.status),
|
||||
order_type_code: transformToBackendOrderType(data.orderType),
|
||||
delivery_date: data.deliveryAddress ? undefined : undefined, // 필드 매핑 필요
|
||||
memo: data.deliveryMemo,
|
||||
// items 변환은 별도 처리 필요
|
||||
};
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// API 함수
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* 발주 목록 조회
|
||||
* GET /api/v1/orders
|
||||
*/
|
||||
export async function getOrderList(params?: {
|
||||
size?: number;
|
||||
@@ -141,61 +199,67 @@ export async function getOrderList(params?: {
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
// 목업 데이터
|
||||
let orders = getMockOrders();
|
||||
const queryParams: Record<string, string> = {};
|
||||
|
||||
// 날짜 필터
|
||||
if (params?.startDate && params?.endDate) {
|
||||
orders = orders.filter((order) => {
|
||||
return order.periodStart >= params.startDate! && order.periodEnd <= params.endDate!;
|
||||
});
|
||||
}
|
||||
// 페이지네이션
|
||||
if (params?.page) queryParams.page = String(params.page);
|
||||
if (params?.size) queryParams.size = String(params.size);
|
||||
|
||||
// 상태 필터
|
||||
// 검색
|
||||
if (params?.search) queryParams.q = params.search;
|
||||
|
||||
// 상태 필터 (Frontend → Backend 변환)
|
||||
if (params?.status && params.status !== 'all') {
|
||||
orders = orders.filter((order) => order.status === params.status);
|
||||
queryParams.status = transformToBackendStatus(params.status as OrderStatus);
|
||||
}
|
||||
|
||||
// 거래처 필터
|
||||
if (params?.partnerId && params.partnerId !== 'all') {
|
||||
orders = orders.filter((order) => order.partnerId === params.partnerId);
|
||||
queryParams.client_id = params.partnerId;
|
||||
}
|
||||
|
||||
// 검색
|
||||
if (params?.search) {
|
||||
const search = params.search.toLowerCase();
|
||||
orders = orders.filter(
|
||||
(order) =>
|
||||
order.orderNumber.toLowerCase().includes(search) ||
|
||||
order.partnerName.toLowerCase().includes(search) ||
|
||||
order.siteName.toLowerCase().includes(search) ||
|
||||
order.orderManager.toLowerCase().includes(search)
|
||||
);
|
||||
// 날짜 범위 필터
|
||||
if (params?.startDate) {
|
||||
queryParams.date_from = params.startDate;
|
||||
}
|
||||
if (params?.endDate) {
|
||||
queryParams.date_to = params.endDate;
|
||||
}
|
||||
|
||||
// 페이지네이션
|
||||
const page = params?.page || 1;
|
||||
const size = params?.size || 1000;
|
||||
const start = (page - 1) * size;
|
||||
const paginatedOrders = orders.slice(start, start + size);
|
||||
const response = await apiClient.get<{
|
||||
data: ApiOrder[];
|
||||
meta?: { total: number; current_page: number; per_page: number };
|
||||
total?: number;
|
||||
current_page?: number;
|
||||
per_page?: number;
|
||||
}>('/orders', { params: queryParams });
|
||||
|
||||
// API 응답 구조 처리
|
||||
const orders = Array.isArray(response.data) ? response.data : (response.data as unknown as ApiOrder[]);
|
||||
const meta = response.meta || {
|
||||
total: response.total || orders.length,
|
||||
current_page: response.current_page || params?.page || 1,
|
||||
per_page: response.per_page || params?.size || 20,
|
||||
};
|
||||
|
||||
const transformedOrders = orders.map(transformOrder);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
items: paginatedOrders,
|
||||
total: orders.length,
|
||||
items: transformedOrders,
|
||||
total: meta.total,
|
||||
},
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
success: false,
|
||||
error: '발주 목록 조회에 실패했습니다.',
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('발주 목록 조회 오류:', error);
|
||||
return { success: false, error: '발주 목록을 불러오는데 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 발주 통계 조회
|
||||
* GET /api/v1/orders/stats
|
||||
*/
|
||||
export async function getOrderStats(): Promise<{
|
||||
success: boolean;
|
||||
@@ -203,52 +267,44 @@ export async function getOrderStats(): Promise<{
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
const orders = getMockOrders();
|
||||
|
||||
const stats: OrderStats = {
|
||||
total: orders.length,
|
||||
waiting: orders.filter((o) => o.status === 'waiting').length,
|
||||
orderComplete: orders.filter((o) => o.status === 'order_complete').length,
|
||||
deliveryScheduled: orders.filter((o) => o.status === 'delivery_scheduled').length,
|
||||
deliveryComplete: orders.filter((o) => o.status === 'delivery_complete').length,
|
||||
};
|
||||
const response = await apiClient.get<ApiOrderStats>('/orders/stats');
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: stats,
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
success: false,
|
||||
error: '발주 통계 조회에 실패했습니다.',
|
||||
data: {
|
||||
total: response.total,
|
||||
waiting: response.draft,
|
||||
orderComplete: response.confirmed,
|
||||
deliveryScheduled: response.in_progress,
|
||||
deliveryComplete: response.completed,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('발주 통계 조회 오류:', error);
|
||||
return { success: false, error: '발주 통계를 불러오는데 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 발주 삭제
|
||||
* DELETE /api/v1/orders/{id}
|
||||
*/
|
||||
export async function deleteOrder(id: string): Promise<{
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
// 목업: 실제로는 API 호출
|
||||
if (cachedOrders) {
|
||||
cachedOrders = cachedOrders.filter((o) => o.id !== id);
|
||||
}
|
||||
|
||||
await apiClient.delete(`/orders/${id}`);
|
||||
return { success: true };
|
||||
} catch {
|
||||
return {
|
||||
success: false,
|
||||
error: '발주 삭제에 실패했습니다.',
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('발주 삭제 오류:', error);
|
||||
return { success: false, error: '발주 삭제에 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 발주 일괄 삭제
|
||||
* Backend에 batch API가 없으므로 개별 삭제 반복
|
||||
*/
|
||||
export async function deleteOrders(ids: string[]): Promise<{
|
||||
success: boolean;
|
||||
@@ -256,32 +312,28 @@ export async function deleteOrders(ids: string[]): Promise<{
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
// 목업: 실제로는 API 호출
|
||||
if (cachedOrders) {
|
||||
const beforeCount = cachedOrders.length;
|
||||
cachedOrders = cachedOrders.filter((o) => !ids.includes(o.id));
|
||||
const deletedCount = beforeCount - cachedOrders.length;
|
||||
let deletedCount = 0;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
deletedCount,
|
||||
};
|
||||
for (const id of ids) {
|
||||
try {
|
||||
await apiClient.delete(`/orders/${id}`);
|
||||
deletedCount++;
|
||||
} catch {
|
||||
// 개별 삭제 실패는 무시하고 계속 진행
|
||||
console.warn(`발주 삭제 실패: ${id}`);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
deletedCount: ids.length,
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
success: false,
|
||||
error: '발주 일괄 삭제에 실패했습니다.',
|
||||
};
|
||||
return { success: true, deletedCount };
|
||||
} catch (error) {
|
||||
console.error('발주 일괄 삭제 오류:', error);
|
||||
return { success: false, error: '발주 일괄 삭제에 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 발주 상세 조회
|
||||
* GET /api/v1/orders/{id}
|
||||
*/
|
||||
export async function getOrderDetail(id: string): Promise<{
|
||||
success: boolean;
|
||||
@@ -289,30 +341,17 @@ export async function getOrderDetail(id: string): Promise<{
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
const orders = getMockOrders();
|
||||
const order = orders.find((o) => o.id === id);
|
||||
|
||||
if (!order) {
|
||||
return {
|
||||
success: false,
|
||||
error: '발주를 찾을 수 없습니다.',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: order,
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
success: false,
|
||||
error: '발주 상세 조회에 실패했습니다.',
|
||||
};
|
||||
const response = await apiClient.get<ApiOrder>(`/orders/${id}`);
|
||||
return { success: true, data: transformOrder(response) };
|
||||
} catch (error) {
|
||||
console.error('발주 상세 조회 오류:', error);
|
||||
return { success: false, error: '발주를 찾을 수 없습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 발주 상세 조회 (전체 정보)
|
||||
* GET /api/v1/orders/{id}
|
||||
*/
|
||||
export async function getOrderDetailFull(id: string): Promise<{
|
||||
success: boolean;
|
||||
@@ -320,27 +359,17 @@ export async function getOrderDetailFull(id: string): Promise<{
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
// 목업: 실제로는 API 호출
|
||||
// 임시로 MOCK_ORDER_DETAIL 반환
|
||||
const mockDetail: OrderDetail = {
|
||||
...MOCK_ORDER_DETAIL,
|
||||
id,
|
||||
};
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: mockDetail,
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
success: false,
|
||||
error: '발주 상세 조회에 실패했습니다.',
|
||||
};
|
||||
const response = await apiClient.get<ApiOrder>(`/orders/${id}`);
|
||||
return { success: true, data: transformOrderDetail(response) };
|
||||
} catch (error) {
|
||||
console.error('발주 상세 조회 오류:', error);
|
||||
return { success: false, error: '발주 상세 조회에 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 발주 수정
|
||||
* PUT /api/v1/orders/{id}
|
||||
*/
|
||||
export async function updateOrder(
|
||||
id: string,
|
||||
@@ -350,20 +379,18 @@ export async function updateOrder(
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
// 목업: 실제로는 API 호출
|
||||
console.log('Updating order:', id, data);
|
||||
|
||||
const apiData = transformOrderToApi(data);
|
||||
await apiClient.put(`/orders/${id}`, apiData);
|
||||
return { success: true };
|
||||
} catch {
|
||||
return {
|
||||
success: false,
|
||||
error: '발주 수정에 실패했습니다.',
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('발주 수정 오류:', error);
|
||||
return { success: false, error: '발주 수정에 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 발주 복제
|
||||
* 기존 발주를 조회 후 새로 생성
|
||||
*/
|
||||
export async function duplicateOrder(id: string): Promise<{
|
||||
success: boolean;
|
||||
@@ -371,18 +398,83 @@ export async function duplicateOrder(id: string): Promise<{
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
// 목업: 실제로는 API 호출
|
||||
const newId = `order-${Date.now()}`;
|
||||
console.log('Duplicating order:', id, '-> new id:', newId);
|
||||
// 1. 기존 발주 조회
|
||||
const existingOrder = await apiClient.get<ApiOrder>(`/orders/${id}`);
|
||||
|
||||
// 2. 새 발주 생성 (order_no는 자동 생성됨)
|
||||
const newOrderData = {
|
||||
client_id: existingOrder.client_id,
|
||||
site_name: existingOrder.site_name,
|
||||
status_code: 'DRAFT', // 복제된 발주는 항상 임시저장
|
||||
order_type_code: existingOrder.order_type_code,
|
||||
delivery_date: existingOrder.delivery_date,
|
||||
memo: existingOrder.memo ? `[복제] ${existingOrder.memo}` : '[복제됨]',
|
||||
items: existingOrder.items?.map((item) => ({
|
||||
item_id: item.item_id,
|
||||
item_name: item.item_name,
|
||||
specification: item.specification,
|
||||
quantity: item.quantity,
|
||||
unit: item.unit,
|
||||
unit_price: item.unit_price,
|
||||
})),
|
||||
};
|
||||
|
||||
const response = await apiClient.post<{ id: number }>('/orders', newOrderData);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
newId,
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
success: false,
|
||||
error: '발주 복제에 실패했습니다.',
|
||||
newId: String(response.id),
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('발주 복제 오류:', error);
|
||||
return { success: false, error: '발주 복제에 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 발주 생성
|
||||
* POST /api/v1/orders
|
||||
*/
|
||||
export async function createOrder(data: OrderDetailFormData): Promise<{
|
||||
success: boolean;
|
||||
data?: { id: string };
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
const apiData = transformOrderToApi(data);
|
||||
const response = await apiClient.post<{ id: number }>('/orders', apiData);
|
||||
|
||||
return { success: true, data: { id: String(response.id) } };
|
||||
} catch (error) {
|
||||
console.error('발주 생성 오류:', error);
|
||||
return { success: false, error: '발주 생성에 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 발주 상태 변경
|
||||
* PATCH /api/v1/orders/{id}/status
|
||||
*/
|
||||
export async function updateOrderStatus(
|
||||
id: string,
|
||||
status: OrderStatus
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
await apiClient.patch(`/orders/${id}/status`, {
|
||||
status: transformToBackendStatus(status),
|
||||
});
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('발주 상태 변경 오류:', error);
|
||||
return { success: false, error: '발주 상태 변경에 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 공통 코드 조회 (재사용)
|
||||
// ========================================
|
||||
|
||||
export { getOrderStatusOptions, getOrderTypeOptions };
|
||||
@@ -33,13 +33,13 @@ import { toast } from 'sonner';
|
||||
import type { Partner, PartnerFormData, PartnerMemo, PartnerDocument } from './types';
|
||||
import {
|
||||
PARTNER_TYPE_OPTIONS,
|
||||
CATEGORY_OPTIONS,
|
||||
CREDIT_RATING_OPTIONS,
|
||||
TRANSACTION_GRADE_OPTIONS,
|
||||
PAYMENT_DAY_OPTIONS,
|
||||
getEmptyPartnerFormData,
|
||||
partnerToFormData,
|
||||
} from './types';
|
||||
import { createPartner, updatePartner, deletePartner } from './actions';
|
||||
|
||||
// 목업 문서 목록 (상세 모드에서 다운로드 버튼 테스트용)
|
||||
const MOCK_DOCUMENTS: PartnerDocument[] = [
|
||||
@@ -158,8 +158,19 @@ export default function PartnerForm({ mode, partnerId, initialData }: PartnerFor
|
||||
const handleConfirmSave = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
// TODO: 실제 API 연동
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
let result;
|
||||
if (isNewMode) {
|
||||
result = await createPartner(formData);
|
||||
} else if (partnerId) {
|
||||
result = await updatePartner(partnerId, formData);
|
||||
} else {
|
||||
throw new Error('거래처 ID가 없습니다.');
|
||||
}
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || '저장에 실패했습니다.');
|
||||
}
|
||||
|
||||
toast.success(isNewMode ? '거래처가 등록되었습니다.' : '수정이 완료되었습니다.');
|
||||
setShowSaveDialog(false);
|
||||
router.push('/ko/construction/project/bidding/partners');
|
||||
@@ -169,7 +180,7 @@ export default function PartnerForm({ mode, partnerId, initialData }: PartnerFor
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [isNewMode, router]);
|
||||
}, [isNewMode, partnerId, formData, router]);
|
||||
|
||||
// 삭제 핸들러
|
||||
const handleDelete = useCallback(() => {
|
||||
@@ -177,10 +188,19 @@ export default function PartnerForm({ mode, partnerId, initialData }: PartnerFor
|
||||
}, []);
|
||||
|
||||
const handleConfirmDelete = useCallback(async () => {
|
||||
if (!partnerId) {
|
||||
toast.error('거래처 ID가 없습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
// TODO: 실제 API 연동
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
const result = await deletePartner(partnerId);
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || '삭제에 실패했습니다.');
|
||||
}
|
||||
|
||||
toast.success('거래처가 삭제되었습니다.');
|
||||
setShowDeleteDialog(false);
|
||||
router.push('/ko/construction/project/bidding/partners');
|
||||
@@ -190,7 +210,7 @@ export default function PartnerForm({ mode, partnerId, initialData }: PartnerFor
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [router]);
|
||||
}, [partnerId, router]);
|
||||
|
||||
// 메모 추가 핸들러
|
||||
const handleAddMemo = useCallback(() => {
|
||||
@@ -493,8 +513,7 @@ export default function PartnerForm({ mode, partnerId, initialData }: PartnerFor
|
||||
{renderField('대표자명', 'representative', formData.representative)}
|
||||
{renderSelectField('거래처 유형', 'partnerType', formData.partnerType, PARTNER_TYPE_OPTIONS)}
|
||||
{renderField('업태', 'businessType', formData.businessType)}
|
||||
{renderSelectField('업종', 'category', formData.category, CATEGORY_OPTIONS)}
|
||||
{renderField('업종(직접입력)', 'businessCategory', formData.businessCategory)}
|
||||
{renderField('업종', 'businessCategory', formData.businessCategory)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
|
||||
@@ -1,373 +1,357 @@
|
||||
'use server';
|
||||
|
||||
import type { Partner, PartnerStats, PartnerFilter, PartnerListResponse, PartnerFormData } from './types';
|
||||
import { apiClient } from '@/lib/api';
|
||||
|
||||
/**
|
||||
* 주일 기업 - 거래처 관리 Server Actions
|
||||
* TODO: 실제 API 연동 시 구현
|
||||
* 표준화된 apiClient 사용 버전
|
||||
*/
|
||||
|
||||
// 목업 데이터 (확장된 타입 적용)
|
||||
const mockPartners: Partner[] = [
|
||||
{
|
||||
id: '1',
|
||||
partnerCode: 'P-001',
|
||||
businessNumber: '123-12-12345',
|
||||
partnerName: '대한건설',
|
||||
representative: '홍길동',
|
||||
partnerType: 'sales',
|
||||
businessType: '건설업',
|
||||
businessCategory: '토목건축',
|
||||
zipCode: '06234',
|
||||
address1: '서울특별시 서초구 서초대로 123',
|
||||
address2: '대한건물 12층 1201호',
|
||||
phone: '02-1234-1234',
|
||||
mobile: '010-1234-1234',
|
||||
fax: '02-1234-1235',
|
||||
email: 'abc@email.com',
|
||||
manager: '담당자명',
|
||||
managerPhone: '010-1234-1234',
|
||||
systemManager: '관리자명',
|
||||
logoUrl: null,
|
||||
logoBlob: null,
|
||||
salesPaymentDay: 15,
|
||||
creditRating: 'AAA',
|
||||
transactionGrade: 'A',
|
||||
taxInvoiceEmail: 'abc@email.com',
|
||||
outstandingAmount: 11000000,
|
||||
overdueDays: 15,
|
||||
overdueToggle: true,
|
||||
badDebtToggle: false,
|
||||
memos: [
|
||||
{
|
||||
id: '1',
|
||||
content: '2025-12-12 12:21 [홍길동] 메모 내용',
|
||||
createdAt: '2025-12-12T12:21:00Z',
|
||||
},
|
||||
],
|
||||
documents: [],
|
||||
category: '건설사',
|
||||
paymentDay: 15,
|
||||
isBadDebt: false,
|
||||
isActive: true,
|
||||
createdAt: '2025-01-01',
|
||||
updatedAt: '2025-01-01',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
partnerCode: 'P-002',
|
||||
businessNumber: '456-45-45678',
|
||||
partnerName: '삼성시공',
|
||||
representative: '김철수',
|
||||
partnerType: 'purchase',
|
||||
businessType: '시공업',
|
||||
businessCategory: '건축시공',
|
||||
zipCode: '06235',
|
||||
address1: '서울특별시 강남구 테헤란로 456',
|
||||
address2: '삼성빌딩 5층',
|
||||
phone: '02-5678-5678',
|
||||
mobile: '010-5678-5678',
|
||||
fax: '02-5678-5679',
|
||||
email: 'samsung@email.com',
|
||||
manager: '이영희',
|
||||
managerPhone: '010-5678-5678',
|
||||
systemManager: '',
|
||||
logoUrl: null,
|
||||
logoBlob: null,
|
||||
salesPaymentDay: 10,
|
||||
creditRating: 'AA',
|
||||
transactionGrade: 'B',
|
||||
taxInvoiceEmail: 'tax@samsung.com',
|
||||
outstandingAmount: 5000000,
|
||||
overdueDays: 0,
|
||||
overdueToggle: false,
|
||||
badDebtToggle: false,
|
||||
memos: [],
|
||||
documents: [],
|
||||
category: '시공사',
|
||||
paymentDay: 10,
|
||||
isBadDebt: false,
|
||||
isActive: true,
|
||||
createdAt: '2025-01-02',
|
||||
updatedAt: '2025-01-02',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
partnerCode: 'P-003',
|
||||
businessNumber: '789-78-78901',
|
||||
partnerName: 'LG건설',
|
||||
representative: '박영수',
|
||||
partnerType: 'both',
|
||||
businessType: '종합건설',
|
||||
businessCategory: '건설',
|
||||
zipCode: '06236',
|
||||
address1: '서울특별시 영등포구 여의대로 789',
|
||||
address2: 'LG타워 20층',
|
||||
phone: '02-7890-7890',
|
||||
mobile: '010-7890-7890',
|
||||
fax: '02-7890-7891',
|
||||
email: 'lg@email.com',
|
||||
manager: '최민수',
|
||||
managerPhone: '010-7890-7890',
|
||||
systemManager: '시스템관리자',
|
||||
logoUrl: null,
|
||||
logoBlob: null,
|
||||
salesPaymentDay: 20,
|
||||
creditRating: 'BBB',
|
||||
transactionGrade: 'C',
|
||||
taxInvoiceEmail: 'tax@lg.com',
|
||||
outstandingAmount: 20000000,
|
||||
overdueDays: 30,
|
||||
overdueToggle: true,
|
||||
badDebtToggle: true,
|
||||
memos: [],
|
||||
documents: [],
|
||||
category: '건설사',
|
||||
paymentDay: 20,
|
||||
isBadDebt: true,
|
||||
isActive: true,
|
||||
createdAt: '2025-01-03',
|
||||
updatedAt: '2025-01-03',
|
||||
},
|
||||
];
|
||||
// ========================================
|
||||
// API 응답 타입
|
||||
// ========================================
|
||||
|
||||
// 거래처 목록 조회
|
||||
export async function getPartnerList(
|
||||
filter?: PartnerFilter
|
||||
): Promise<{ success: boolean; data?: PartnerListResponse; error?: string }> {
|
||||
interface ApiPartner {
|
||||
id: number;
|
||||
client_code: string | null;
|
||||
business_no: string | null;
|
||||
name: string;
|
||||
contact_person: string | null;
|
||||
client_type: string | null;
|
||||
business_type: string | null;
|
||||
business_item: string | null;
|
||||
address: string | null;
|
||||
phone: string | null;
|
||||
mobile: string | null;
|
||||
fax: string | null;
|
||||
email: string | null;
|
||||
manager_name: string | null;
|
||||
manager_tel: string | null;
|
||||
system_manager: string | null;
|
||||
outstanding_amount: number | null;
|
||||
is_overdue: boolean;
|
||||
has_bad_debt: boolean;
|
||||
is_active: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
interface ApiPartnerStats {
|
||||
total: number;
|
||||
sales: number;
|
||||
purchase: number;
|
||||
both: number;
|
||||
badDebt: number;
|
||||
normal: number;
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 타입 변환 함수
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* client_type API → Frontend partnerType 변환
|
||||
*/
|
||||
function transformClientType(clientType: string | null | undefined): Partner['partnerType'] {
|
||||
const typeMap: Record<string, Partner['partnerType']> = {
|
||||
SALES: 'sales',
|
||||
PURCHASE: 'purchase',
|
||||
BOTH: 'both',
|
||||
};
|
||||
return typeMap[clientType || ''] || 'sales';
|
||||
}
|
||||
|
||||
/**
|
||||
* partnerType Frontend → API client_type 변환
|
||||
*/
|
||||
function transformPartnerType(partnerType: Partner['partnerType']): string {
|
||||
const typeMap: Record<Partner['partnerType'], string> = {
|
||||
sales: 'SALES',
|
||||
purchase: 'PURCHASE',
|
||||
both: 'BOTH',
|
||||
};
|
||||
return typeMap[partnerType] || 'SALES';
|
||||
}
|
||||
|
||||
/**
|
||||
* client_type → 구분 라벨 변환
|
||||
*/
|
||||
function getPartnerTypeLabel(clientType: string | null | undefined): string {
|
||||
const labelMap: Record<string, string> = {
|
||||
SALES: '매출',
|
||||
PURCHASE: '매입',
|
||||
BOTH: '복합',
|
||||
};
|
||||
return labelMap[clientType || ''] || '매출';
|
||||
}
|
||||
|
||||
/**
|
||||
* API 응답 → Partner 타입 변환
|
||||
*/
|
||||
function transformPartner(apiData: ApiPartner): Partner {
|
||||
return {
|
||||
id: String(apiData.id),
|
||||
partnerCode: apiData.client_code || '',
|
||||
businessNumber: apiData.business_no || '',
|
||||
partnerName: apiData.name || '',
|
||||
representative: apiData.contact_person || '',
|
||||
partnerType: transformClientType(apiData.client_type),
|
||||
businessType: apiData.business_type || '',
|
||||
businessCategory: apiData.business_item || '',
|
||||
zipCode: '',
|
||||
address1: apiData.address || '',
|
||||
address2: '',
|
||||
phone: apiData.phone || '',
|
||||
mobile: apiData.mobile || '',
|
||||
fax: apiData.fax || '',
|
||||
email: apiData.email || '',
|
||||
manager: apiData.manager_name || '',
|
||||
managerPhone: apiData.manager_tel || '',
|
||||
systemManager: apiData.system_manager || '',
|
||||
logoUrl: null,
|
||||
logoBlob: null,
|
||||
salesPaymentDay: 0,
|
||||
creditRating: '',
|
||||
transactionGrade: '',
|
||||
taxInvoiceEmail: apiData.email || '',
|
||||
outstandingAmount: apiData.outstanding_amount || 0,
|
||||
overdueDays: apiData.is_overdue ? 30 : 0,
|
||||
overdueToggle: apiData.is_overdue,
|
||||
badDebtToggle: apiData.has_bad_debt,
|
||||
memos: [],
|
||||
documents: [],
|
||||
category: getPartnerTypeLabel(apiData.client_type),
|
||||
paymentDay: 0,
|
||||
isBadDebt: apiData.has_bad_debt,
|
||||
isActive: apiData.is_active !== false,
|
||||
createdAt: apiData.created_at || '',
|
||||
updatedAt: apiData.updated_at || '',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* PartnerFormData → API 요청 데이터 변환
|
||||
*/
|
||||
function transformPartnerToApi(data: PartnerFormData): Record<string, unknown> {
|
||||
return {
|
||||
business_no: data.businessNumber,
|
||||
name: data.partnerName,
|
||||
contact_person: data.representative,
|
||||
client_type: transformPartnerType(data.partnerType),
|
||||
business_type: data.businessType,
|
||||
business_item: data.businessCategory,
|
||||
address: data.address1 + (data.address2 ? ` ${data.address2}` : ''),
|
||||
phone: data.phone,
|
||||
mobile: data.mobile,
|
||||
fax: data.fax,
|
||||
email: data.email,
|
||||
manager_name: data.manager,
|
||||
manager_tel: data.managerPhone,
|
||||
system_manager: data.systemManager,
|
||||
is_overdue: data.overdueToggle,
|
||||
is_active: true,
|
||||
};
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// API 함수
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* 거래처 목록 조회
|
||||
* GET /api/v1/clients
|
||||
*/
|
||||
export async function getPartnerList(filter?: PartnerFilter): Promise<{
|
||||
success: boolean;
|
||||
data?: PartnerListResponse;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
// TODO: 실제 API 호출
|
||||
let filtered = [...mockPartners];
|
||||
const queryParams: Record<string, string> = {};
|
||||
|
||||
// 검색 필터
|
||||
if (filter?.search) {
|
||||
const search = filter.search.toLowerCase();
|
||||
filtered = filtered.filter(
|
||||
(p) =>
|
||||
p.partnerName.toLowerCase().includes(search) ||
|
||||
p.partnerCode.toLowerCase().includes(search) ||
|
||||
p.representative.toLowerCase().includes(search)
|
||||
);
|
||||
}
|
||||
// 검색어
|
||||
if (filter?.search) queryParams.q = filter.search;
|
||||
|
||||
// 악성채권 필터
|
||||
// 페이지네이션
|
||||
if (filter?.page) queryParams.page = String(filter.page);
|
||||
if (filter?.size) queryParams.size = String(filter.size);
|
||||
|
||||
// API 응답 구조: { success, data: { data: [...], current_page, per_page, total, last_page } }
|
||||
const response = await apiClient.get<{
|
||||
success: boolean;
|
||||
data: {
|
||||
data: ApiPartner[];
|
||||
current_page: number;
|
||||
per_page: number;
|
||||
total: number;
|
||||
last_page: number;
|
||||
};
|
||||
}>('/clients', { params: queryParams });
|
||||
|
||||
const paginated = response.data;
|
||||
let items = (paginated?.data || []).map(transformPartner);
|
||||
|
||||
// 악성채권 필터 (프론트엔드에서 처리)
|
||||
if (filter?.badDebtFilter && filter.badDebtFilter !== 'all') {
|
||||
filtered = filtered.filter((p) =>
|
||||
filter.badDebtFilter === 'badDebt' ? p.isBadDebt : !p.isBadDebt
|
||||
);
|
||||
items = items.filter((p) => (filter.badDebtFilter === 'badDebt' ? p.isBadDebt : !p.isBadDebt));
|
||||
}
|
||||
|
||||
// 정렬
|
||||
// 정렬 (프론트엔드에서 처리)
|
||||
if (filter?.sortBy) {
|
||||
switch (filter.sortBy) {
|
||||
case 'latest':
|
||||
filtered.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
||||
items.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
||||
break;
|
||||
case 'oldest':
|
||||
filtered.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
|
||||
items.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
|
||||
break;
|
||||
case 'nameAsc':
|
||||
filtered.sort((a, b) => a.partnerName.localeCompare(b.partnerName));
|
||||
items.sort((a, b) => a.partnerName.localeCompare(b.partnerName));
|
||||
break;
|
||||
case 'nameDesc':
|
||||
filtered.sort((a, b) => b.partnerName.localeCompare(a.partnerName));
|
||||
items.sort((a, b) => b.partnerName.localeCompare(a.partnerName));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const page = filter?.page ?? 1;
|
||||
const size = filter?.size ?? 20;
|
||||
const start = (page - 1) * size;
|
||||
const paginatedItems = filtered.slice(start, start + size);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
items: paginatedItems,
|
||||
total: filtered.length,
|
||||
page,
|
||||
size,
|
||||
totalPages: Math.ceil(filtered.length / size),
|
||||
items,
|
||||
total: paginated?.total || 0,
|
||||
page: paginated?.current_page || 1,
|
||||
size: paginated?.per_page || 20,
|
||||
totalPages: paginated?.last_page || 1,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('getPartnerList error:', error);
|
||||
return { success: false, error: '거래처 목록 조회에 실패했습니다.' };
|
||||
console.error('거래처 목록 조회 오류:', error);
|
||||
return { success: false, error: '거래처 목록을 불러오는데 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// 거래처 상세 조회
|
||||
export async function getPartner(
|
||||
id: string
|
||||
): Promise<{ success: boolean; data?: Partner; error?: string }> {
|
||||
/**
|
||||
* 거래처 상세 조회
|
||||
* GET /api/v1/clients/{id}
|
||||
*/
|
||||
export async function getPartner(id: string): Promise<{
|
||||
success: boolean;
|
||||
data?: Partner;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
// TODO: 실제 API 호출
|
||||
const partner = mockPartners.find((p) => p.id === id);
|
||||
|
||||
if (!partner) {
|
||||
return { success: false, error: '거래처를 찾을 수 없습니다.' };
|
||||
}
|
||||
|
||||
return { success: true, data: partner };
|
||||
// API 응답 구조: { success, data: {...single item} }
|
||||
const response = await apiClient.get<{ success: boolean; data: ApiPartner }>(`/clients/${id}`);
|
||||
return { success: true, data: transformPartner(response.data) };
|
||||
} catch (error) {
|
||||
console.error('getPartner error:', error);
|
||||
return { success: false, error: '거래처 조회에 실패했습니다.' };
|
||||
console.error('거래처 조회 오류:', error);
|
||||
return { success: false, error: '거래처를 찾을 수 없습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// 거래처 등록
|
||||
export async function createPartner(
|
||||
data: PartnerFormData
|
||||
): Promise<{ success: boolean; data?: Partner; error?: string }> {
|
||||
/**
|
||||
* 거래처 등록
|
||||
* POST /api/v1/clients
|
||||
*/
|
||||
export async function createPartner(data: PartnerFormData): Promise<{
|
||||
success: boolean;
|
||||
data?: Partner;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
// TODO: 실제 API 호출
|
||||
console.log('Create partner:', data);
|
||||
|
||||
const newPartner: Partner = {
|
||||
id: String(Date.now()),
|
||||
partnerCode: `P-${String(mockPartners.length + 1).padStart(3, '0')}`,
|
||||
businessNumber: data.businessNumber,
|
||||
partnerName: data.partnerName,
|
||||
representative: data.representative,
|
||||
partnerType: data.partnerType,
|
||||
businessType: data.businessType,
|
||||
businessCategory: data.businessCategory,
|
||||
zipCode: data.zipCode,
|
||||
address1: data.address1,
|
||||
address2: data.address2,
|
||||
phone: data.phone,
|
||||
mobile: data.mobile,
|
||||
fax: data.fax,
|
||||
email: data.email,
|
||||
manager: data.manager,
|
||||
managerPhone: data.managerPhone,
|
||||
systemManager: data.systemManager,
|
||||
logoUrl: data.logoUrl,
|
||||
logoBlob: data.logoBlob,
|
||||
salesPaymentDay: data.salesPaymentDay,
|
||||
creditRating: data.creditRating,
|
||||
transactionGrade: data.transactionGrade,
|
||||
taxInvoiceEmail: data.taxInvoiceEmail,
|
||||
outstandingAmount: data.outstandingAmount,
|
||||
overdueDays: data.overdueDays,
|
||||
overdueToggle: data.overdueToggle,
|
||||
badDebtToggle: data.badDebtToggle,
|
||||
memos: data.memos,
|
||||
documents: data.documents,
|
||||
category: data.category,
|
||||
paymentDay: data.salesPaymentDay,
|
||||
isBadDebt: data.badDebtToggle,
|
||||
isActive: true,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
return { success: true, data: newPartner };
|
||||
const apiData = transformPartnerToApi(data);
|
||||
// API 응답 구조: { success, data: {...created item} }
|
||||
const response = await apiClient.post<{ success: boolean; data: ApiPartner }>('/clients', apiData);
|
||||
return { success: true, data: transformPartner(response.data) };
|
||||
} catch (error) {
|
||||
console.error('createPartner error:', error);
|
||||
console.error('거래처 등록 오류:', error);
|
||||
return { success: false, error: '거래처 등록에 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// 거래처 수정
|
||||
export async function updatePartner(
|
||||
id: string,
|
||||
data: PartnerFormData
|
||||
): Promise<{ success: boolean; data?: Partner; error?: string }> {
|
||||
/**
|
||||
* 거래처 수정
|
||||
* PUT /api/v1/clients/{id}
|
||||
*/
|
||||
export async function updatePartner(id: string, data: PartnerFormData): Promise<{
|
||||
success: boolean;
|
||||
data?: Partner;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
// TODO: 실제 API 호출
|
||||
console.log('Update partner:', id, data);
|
||||
|
||||
const existingPartner = mockPartners.find((p) => p.id === id);
|
||||
if (!existingPartner) {
|
||||
return { success: false, error: '거래처를 찾을 수 없습니다.' };
|
||||
}
|
||||
|
||||
const updatedPartner: Partner = {
|
||||
...existingPartner,
|
||||
businessNumber: data.businessNumber,
|
||||
partnerName: data.partnerName,
|
||||
representative: data.representative,
|
||||
partnerType: data.partnerType,
|
||||
businessType: data.businessType,
|
||||
businessCategory: data.businessCategory,
|
||||
zipCode: data.zipCode,
|
||||
address1: data.address1,
|
||||
address2: data.address2,
|
||||
phone: data.phone,
|
||||
mobile: data.mobile,
|
||||
fax: data.fax,
|
||||
email: data.email,
|
||||
manager: data.manager,
|
||||
managerPhone: data.managerPhone,
|
||||
systemManager: data.systemManager,
|
||||
logoUrl: data.logoUrl,
|
||||
logoBlob: data.logoBlob,
|
||||
salesPaymentDay: data.salesPaymentDay,
|
||||
creditRating: data.creditRating,
|
||||
transactionGrade: data.transactionGrade,
|
||||
taxInvoiceEmail: data.taxInvoiceEmail,
|
||||
outstandingAmount: data.outstandingAmount,
|
||||
overdueDays: data.overdueDays,
|
||||
overdueToggle: data.overdueToggle,
|
||||
badDebtToggle: data.badDebtToggle,
|
||||
memos: data.memos,
|
||||
documents: data.documents,
|
||||
category: data.category,
|
||||
paymentDay: data.salesPaymentDay,
|
||||
isBadDebt: data.badDebtToggle,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
return { success: true, data: updatedPartner };
|
||||
const apiData = transformPartnerToApi(data);
|
||||
// API 응답 구조: { success, data: {...updated item} }
|
||||
const response = await apiClient.put<{ success: boolean; data: ApiPartner }>(`/clients/${id}`, apiData);
|
||||
return { success: true, data: transformPartner(response.data) };
|
||||
} catch (error) {
|
||||
console.error('updatePartner error:', error);
|
||||
console.error('거래처 수정 오류:', error);
|
||||
return { success: false, error: '거래처 수정에 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// 거래처 통계 조회
|
||||
export async function getPartnerStats(): Promise<{ success: boolean; data?: PartnerStats; error?: string }> {
|
||||
/**
|
||||
* 거래처 통계 조회
|
||||
* GET /api/v1/clients/stats
|
||||
*/
|
||||
export async function getPartnerStats(): Promise<{
|
||||
success: boolean;
|
||||
data?: PartnerStats;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
// TODO: 실제 API 호출
|
||||
const total = mockPartners.length;
|
||||
const badDebt = mockPartners.filter((p) => p.isBadDebt).length;
|
||||
// API 응답 구조: { success, data: {...stats} }
|
||||
const response = await apiClient.get<{ success: boolean; data: ApiPartnerStats }>('/clients/stats');
|
||||
const stats = response.data;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
total,
|
||||
unregistered: 5, // 목업
|
||||
badDebt,
|
||||
normal: total - badDebt,
|
||||
total: stats?.total || 0,
|
||||
unregistered: 0,
|
||||
badDebt: stats?.badDebt || 0,
|
||||
normal: stats?.normal || 0,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('getPartnerStats error:', error);
|
||||
return { success: false, error: '통계 조회에 실패했습니다.' };
|
||||
console.error('거래처 통계 조회 오류:', error);
|
||||
return { success: false, error: '통계를 불러오는데 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// 거래처 삭제
|
||||
export async function deletePartner(id: string): Promise<{ success: boolean; error?: string }> {
|
||||
/**
|
||||
* 거래처 삭제
|
||||
* DELETE /api/v1/clients/{id}
|
||||
*/
|
||||
export async function deletePartner(id: string): Promise<{
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
// TODO: 실제 API 호출
|
||||
console.log('Delete partner:', id);
|
||||
await apiClient.delete(`/clients/${id}`);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('deletePartner error:', error);
|
||||
console.error('거래처 삭제 오류:', error);
|
||||
return { success: false, error: '거래처 삭제에 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// 거래처 일괄 삭제
|
||||
export async function deletePartners(ids: string[]): Promise<{ success: boolean; deletedCount?: number; error?: string }> {
|
||||
/**
|
||||
* 거래처 일괄 삭제
|
||||
* DELETE /api/v1/clients/bulk
|
||||
*/
|
||||
export async function deletePartners(ids: string[]): Promise<{
|
||||
success: boolean;
|
||||
deletedCount?: number;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
// TODO: 실제 API 호출
|
||||
console.log('Delete partners:', ids);
|
||||
await apiClient.delete('/clients/bulk', {
|
||||
data: { ids: ids.map((id) => Number(id)) },
|
||||
});
|
||||
return { success: true, deletedCount: ids.length };
|
||||
} catch (error) {
|
||||
console.error('deletePartners error:', error);
|
||||
console.error('거래처 일괄 삭제 오류:', error);
|
||||
return { success: false, error: '일괄 삭제에 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
@@ -1,388 +1,378 @@
|
||||
'use server';
|
||||
|
||||
import type { Pricing, PricingStats } from './types';
|
||||
import type {
|
||||
Pricing,
|
||||
PricingStats,
|
||||
PricingListResponse,
|
||||
PricingFilter,
|
||||
PricingFormData,
|
||||
} from './types';
|
||||
import { apiClient } from '@/lib/api';
|
||||
|
||||
// ===== 목데이터 =====
|
||||
const mockPricingList: Pricing[] = [
|
||||
{
|
||||
id: '1',
|
||||
pricingNumber: 'PRC-2026-001',
|
||||
itemType: '박스',
|
||||
category: '슬라이드 OPEN 사이즈',
|
||||
itemName: '슬라이드 도어 세트',
|
||||
spec: '1200x2400',
|
||||
orderItems: [
|
||||
{ id: 'oi1', name: '무게', value: '400KG' },
|
||||
{ id: 'oi2', name: '두께', value: '50mm' },
|
||||
],
|
||||
unit: 'SET',
|
||||
division: '일반',
|
||||
vendor: '(주)슬라이드텍',
|
||||
purchasePrice: 850000,
|
||||
marginRate: 15,
|
||||
sellingPrice: 977500,
|
||||
status: 'in_use',
|
||||
createdAt: '2026-01-02',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
pricingNumber: 'PRC-2026-002',
|
||||
itemType: '부속',
|
||||
category: '모터',
|
||||
itemName: '서보모터 750W',
|
||||
spec: 'AC220V',
|
||||
orderItems: [
|
||||
{ id: 'oi3', name: '무게', value: '12KG' },
|
||||
],
|
||||
unit: 'EA',
|
||||
division: '일반',
|
||||
vendor: '삼성전기',
|
||||
purchasePrice: 320000,
|
||||
marginRate: 20,
|
||||
sellingPrice: 384000,
|
||||
status: 'in_use',
|
||||
createdAt: '2026-01-02',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
pricingNumber: 'PRC-2026-003',
|
||||
itemType: '소모품',
|
||||
category: '공정자재',
|
||||
itemName: '용접봉 E7016',
|
||||
spec: '4.0mm x 350mm',
|
||||
orderItems: [
|
||||
{ id: 'oi4', name: '무게', value: '5KG' },
|
||||
],
|
||||
unit: 'BOX',
|
||||
division: '일반',
|
||||
vendor: '현대용접산업',
|
||||
purchasePrice: 45000,
|
||||
marginRate: 25,
|
||||
sellingPrice: 56250,
|
||||
status: 'in_use',
|
||||
createdAt: '2026-01-03',
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
pricingNumber: 'PRC-2026-004',
|
||||
itemType: '공과',
|
||||
category: '철물',
|
||||
itemName: '앵커볼트 세트',
|
||||
spec: 'M12 x 100',
|
||||
orderItems: [
|
||||
{ id: 'oi5', name: '무게', value: '500G' },
|
||||
],
|
||||
unit: 'SET',
|
||||
division: '특수',
|
||||
vendor: '철강볼트',
|
||||
purchasePrice: 12000,
|
||||
marginRate: 30,
|
||||
sellingPrice: 15600,
|
||||
status: 'in_use',
|
||||
createdAt: '2026-01-03',
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
pricingNumber: 'PRC-2026-005',
|
||||
itemType: '박스',
|
||||
category: '슬라이드 OPEN 사이즈',
|
||||
itemName: '자동문 프레임',
|
||||
spec: '900x2100',
|
||||
orderItems: [
|
||||
{ id: 'oi6', name: '무게', value: '280KG' },
|
||||
{ id: 'oi7', name: '두께', value: '40mm' },
|
||||
],
|
||||
unit: 'SET',
|
||||
division: '일반',
|
||||
vendor: '(주)슬라이드텍',
|
||||
purchasePrice: 650000,
|
||||
marginRate: 18,
|
||||
sellingPrice: 767000,
|
||||
status: 'not_registered',
|
||||
createdAt: '2026-01-04',
|
||||
},
|
||||
{
|
||||
id: '6',
|
||||
pricingNumber: 'PRC-2026-006',
|
||||
itemType: '부속',
|
||||
category: '모터',
|
||||
itemName: '기어드모터 1.5KW',
|
||||
spec: 'AC380V',
|
||||
orderItems: [
|
||||
{ id: 'oi8', name: '무게', value: '25KG' },
|
||||
],
|
||||
unit: 'EA',
|
||||
division: '특수',
|
||||
vendor: '삼성전기',
|
||||
purchasePrice: 580000,
|
||||
marginRate: 22,
|
||||
sellingPrice: 707600,
|
||||
status: 'in_use',
|
||||
createdAt: '2026-01-04',
|
||||
},
|
||||
{
|
||||
id: '7',
|
||||
pricingNumber: 'PRC-2026-007',
|
||||
itemType: '소모품',
|
||||
category: '공정자재',
|
||||
itemName: '절삭유 WS-300',
|
||||
spec: '20L',
|
||||
orderItems: [],
|
||||
unit: 'CAN',
|
||||
division: '일반',
|
||||
vendor: '한국윤활유',
|
||||
purchasePrice: 85000,
|
||||
marginRate: 15,
|
||||
sellingPrice: 97750,
|
||||
status: 'not_registered',
|
||||
createdAt: '2026-01-05',
|
||||
},
|
||||
{
|
||||
id: '8',
|
||||
pricingNumber: 'PRC-2026-008',
|
||||
itemType: '공과',
|
||||
category: '철물',
|
||||
itemName: '스테인레스 볼트',
|
||||
spec: 'M10 x 50',
|
||||
orderItems: [
|
||||
{ id: 'oi9', name: '무게', value: '200G' },
|
||||
],
|
||||
unit: 'BOX',
|
||||
division: '일반',
|
||||
vendor: '철강볼트',
|
||||
purchasePrice: 35000,
|
||||
marginRate: 28,
|
||||
sellingPrice: 44800,
|
||||
status: 'in_use',
|
||||
createdAt: '2026-01-05',
|
||||
},
|
||||
];
|
||||
/**
|
||||
* 주일 기업 - 단가관리 Server Actions
|
||||
* 표준화된 apiClient 사용 버전
|
||||
*/
|
||||
|
||||
// ===== 단가 목록 조회 =====
|
||||
export async function getPricingList(params?: {
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
itemType?: string;
|
||||
category?: string;
|
||||
spec?: string;
|
||||
division?: string;
|
||||
status?: string;
|
||||
sort?: string;
|
||||
search?: string;
|
||||
}): Promise<{
|
||||
// ========================================
|
||||
// API 응답 타입
|
||||
// ========================================
|
||||
|
||||
interface ApiPricing {
|
||||
id: number;
|
||||
pricing_number: string;
|
||||
item_type: string | null;
|
||||
category: string | null;
|
||||
item_name: string;
|
||||
spec: string | null;
|
||||
order_items: ApiOrderItem[] | null;
|
||||
unit: string | null;
|
||||
division: string | null;
|
||||
vendor: string | null;
|
||||
vendor_id: number | null;
|
||||
purchase_price: number;
|
||||
margin_rate: number;
|
||||
selling_price: number;
|
||||
status: 'in_use' | 'stopped' | 'not_registered';
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
interface ApiOrderItem {
|
||||
id: number;
|
||||
name: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface ApiPricingStats {
|
||||
total: number;
|
||||
in_use: number;
|
||||
stopped: number;
|
||||
not_registered: number;
|
||||
}
|
||||
|
||||
interface ApiVendor {
|
||||
id: number;
|
||||
name: string;
|
||||
business_no: string | null;
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 타입 변환 함수
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* API 응답 → Pricing 타입 변환
|
||||
*/
|
||||
function transformPricing(apiData: ApiPricing): Pricing {
|
||||
return {
|
||||
id: String(apiData.id),
|
||||
pricingNumber: apiData.pricing_number || '',
|
||||
itemType: apiData.item_type || '',
|
||||
category: apiData.category || '',
|
||||
itemName: apiData.item_name || '',
|
||||
spec: apiData.spec || '',
|
||||
orderItems: (apiData.order_items || []).map((item) => ({
|
||||
id: String(item.id),
|
||||
name: item.name || '',
|
||||
value: item.value || '',
|
||||
})),
|
||||
unit: apiData.unit || '',
|
||||
division: apiData.division || '',
|
||||
vendor: apiData.vendor || '',
|
||||
purchasePrice: apiData.purchase_price || 0,
|
||||
marginRate: apiData.margin_rate || 0,
|
||||
sellingPrice: apiData.selling_price || 0,
|
||||
status: apiData.status || 'not_registered',
|
||||
createdAt: apiData.created_at || '',
|
||||
updatedAt: apiData.updated_at || '',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* PricingFormData → API 요청 데이터 변환
|
||||
*/
|
||||
function transformToApiRequest(data: Partial<PricingFormData>): Record<string, unknown> {
|
||||
const apiData: Record<string, unknown> = {};
|
||||
|
||||
if (data.itemType !== undefined) apiData.item_type = data.itemType || null;
|
||||
if (data.category !== undefined) apiData.category = data.category || null;
|
||||
if (data.itemName !== undefined) apiData.item_name = data.itemName;
|
||||
if (data.spec !== undefined) apiData.spec = data.spec || null;
|
||||
if (data.unit !== undefined) apiData.unit = data.unit || null;
|
||||
if (data.division !== undefined) apiData.division = data.division || null;
|
||||
if (data.vendor !== undefined) apiData.vendor = data.vendor || null;
|
||||
if (data.purchasePrice !== undefined) apiData.purchase_price = data.purchasePrice;
|
||||
if (data.marginRate !== undefined) apiData.margin_rate = data.marginRate;
|
||||
if (data.sellingPrice !== undefined) apiData.selling_price = data.sellingPrice;
|
||||
if (data.status !== undefined) apiData.status = data.status;
|
||||
|
||||
// 주문 항목 변환
|
||||
if (data.orderItems !== undefined) {
|
||||
apiData.order_items = data.orderItems.map((item) => ({
|
||||
name: item.name,
|
||||
value: item.value,
|
||||
}));
|
||||
}
|
||||
|
||||
return apiData;
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// API 함수
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* 단가 목록 조회
|
||||
* GET /api/v1/pricing
|
||||
*/
|
||||
export async function getPricingList(filter?: PricingFilter): Promise<{
|
||||
success: boolean;
|
||||
data?: { items: Pricing[]; total: number };
|
||||
data?: PricingListResponse;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
let filtered = [...mockPricingList];
|
||||
const queryParams: Record<string, string> = {};
|
||||
|
||||
// 품목유형 필터
|
||||
if (params?.itemType && params.itemType !== 'all') {
|
||||
const typeMap: Record<string, string> = {
|
||||
box: '박스',
|
||||
parts: '부속',
|
||||
consumables: '소모품',
|
||||
utility: '공과',
|
||||
};
|
||||
filtered = filtered.filter(p => p.itemType === typeMap[params.itemType!]);
|
||||
}
|
||||
// 검색
|
||||
if (filter?.search) queryParams.search = filter.search;
|
||||
|
||||
// 카테고리 필터
|
||||
if (params?.category && params.category !== 'all') {
|
||||
const categoryMap: Record<string, string> = {
|
||||
slide_open: '슬라이드 OPEN 사이즈',
|
||||
motor: '모터',
|
||||
process_material: '공정자재',
|
||||
hardware: '철물',
|
||||
};
|
||||
filtered = filtered.filter(p => p.category === categoryMap[params.category!]);
|
||||
}
|
||||
// 필터
|
||||
if (filter?.status && filter.status !== 'all') queryParams.status = filter.status;
|
||||
if (filter?.itemType && filter.itemType !== 'all') queryParams.item_type = filter.itemType;
|
||||
if (filter?.category && filter.category !== 'all') queryParams.category = filter.category;
|
||||
if (filter?.division && filter.division !== 'all') queryParams.division = filter.division;
|
||||
|
||||
// 구분 필터
|
||||
if (params?.division && params.division !== 'all') {
|
||||
const divisionMap: Record<string, string> = {
|
||||
general: '일반',
|
||||
special: '특수',
|
||||
};
|
||||
filtered = filtered.filter(p => p.division === divisionMap[params.division!]);
|
||||
}
|
||||
|
||||
// 검색 필터
|
||||
if (params?.search) {
|
||||
const search = params.search.toLowerCase();
|
||||
filtered = filtered.filter(p =>
|
||||
p.pricingNumber.toLowerCase().includes(search) ||
|
||||
p.itemName.toLowerCase().includes(search) ||
|
||||
p.category.toLowerCase().includes(search) ||
|
||||
p.vendor.toLowerCase().includes(search)
|
||||
);
|
||||
}
|
||||
// 페이지네이션
|
||||
if (filter?.page) queryParams.page = String(filter.page);
|
||||
if (filter?.size) queryParams.per_page = String(filter.size);
|
||||
|
||||
// 정렬
|
||||
if (params?.sort === 'oldest') {
|
||||
filtered.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
|
||||
} else if (params?.sort === 'price_high') {
|
||||
filtered.sort((a, b) => b.sellingPrice - a.sellingPrice);
|
||||
} else if (params?.sort === 'price_low') {
|
||||
filtered.sort((a, b) => a.sellingPrice - b.sellingPrice);
|
||||
} else {
|
||||
filtered.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
||||
if (filter?.sortBy) {
|
||||
const sortMap: Record<string, { field: string; dir: string }> = {
|
||||
latest: { field: 'created_at', dir: 'desc' },
|
||||
oldest: { field: 'created_at', dir: 'asc' },
|
||||
itemNameAsc: { field: 'item_name', dir: 'asc' },
|
||||
itemNameDesc: { field: 'item_name', dir: 'desc' },
|
||||
priceAsc: { field: 'selling_price', dir: 'asc' },
|
||||
priceDesc: { field: 'selling_price', dir: 'desc' },
|
||||
price_high: { field: 'selling_price', dir: 'desc' },
|
||||
price_low: { field: 'selling_price', dir: 'asc' },
|
||||
};
|
||||
const sort = sortMap[filter.sortBy];
|
||||
if (sort) {
|
||||
queryParams.sort_by = sort.field;
|
||||
queryParams.sort_dir = sort.dir;
|
||||
}
|
||||
}
|
||||
|
||||
const response = await apiClient.get<{
|
||||
data: ApiPricing[];
|
||||
current_page: number;
|
||||
per_page: number;
|
||||
total: number;
|
||||
last_page: number;
|
||||
}>('/pricing', { params: queryParams });
|
||||
|
||||
const items = (response.data || []).map(transformPricing);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
items: filtered,
|
||||
total: filtered.length,
|
||||
items,
|
||||
total: response.total || 0,
|
||||
page: response.current_page || 1,
|
||||
size: response.per_page || 20,
|
||||
totalPages: response.last_page || 1,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[getPricingList] Error:', error);
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
console.error('단가 목록 조회 오류:', error);
|
||||
return { success: false, error: '단가 목록을 불러오는데 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 통계 조회 =====
|
||||
/**
|
||||
* 단가 통계 조회
|
||||
* GET /api/v1/pricing/stats
|
||||
*/
|
||||
export async function getPricingStats(): Promise<{
|
||||
success: boolean;
|
||||
data?: PricingStats;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
const stats: PricingStats = {
|
||||
total: mockPricingList.length,
|
||||
inUse: mockPricingList.filter(p => p.status === 'in_use').length,
|
||||
notRegistered: mockPricingList.filter(p => p.status === 'not_registered').length,
|
||||
const response = await apiClient.get<ApiPricingStats>('/pricing/stats');
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
total: response.total || 0,
|
||||
inUse: response.in_use || 0,
|
||||
notRegistered: response.not_registered || 0,
|
||||
},
|
||||
};
|
||||
|
||||
return { success: true, data: stats };
|
||||
} catch (error) {
|
||||
console.error('[getPricingStats] Error:', error);
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
console.error('단가 통계 조회 오류:', error);
|
||||
return { success: false, error: '통계를 불러오는데 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 단일 삭제 =====
|
||||
export async function deletePricing(id: string): Promise<{
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
// 목데이터에서는 실제 삭제하지 않음
|
||||
const index = mockPricingList.findIndex(p => p.id === id);
|
||||
if (index === -1) {
|
||||
return { success: false, error: '단가를 찾을 수 없습니다.' };
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('[deletePricing] Error:', error);
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 일괄 삭제 =====
|
||||
export async function deletePricings(ids: string[]): Promise<{
|
||||
success: boolean;
|
||||
deletedCount?: number;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
return { success: true, deletedCount: ids.length };
|
||||
} catch (error) {
|
||||
console.error('[deletePricings] Error:', error);
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 단가 상세 조회 =====
|
||||
/**
|
||||
* 단가 상세 조회
|
||||
* GET /api/v1/pricing/{id}
|
||||
*/
|
||||
export async function getPricingDetail(id: string): Promise<{
|
||||
success: boolean;
|
||||
data?: Pricing;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
const pricing = mockPricingList.find(p => p.id === id);
|
||||
if (!pricing) {
|
||||
return { success: false, error: '단가를 찾을 수 없습니다.' };
|
||||
}
|
||||
return { success: true, data: pricing };
|
||||
const response = await apiClient.get<ApiPricing>(`/pricing/${id}`);
|
||||
return { success: true, data: transformPricing(response) };
|
||||
} catch (error) {
|
||||
console.error('[getPricingDetail] Error:', error);
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
console.error('단가 상세 조회 오류:', error);
|
||||
return { success: false, error: '단가 정보를 찾을 수 없습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 단가 생성 =====
|
||||
export async function createPricing(data: Omit<Pricing, 'id' | 'pricingNumber' | 'createdAt'>): Promise<{
|
||||
/**
|
||||
* 단가 등록
|
||||
* POST /api/v1/pricing
|
||||
*/
|
||||
export async function createPricing(data: PricingFormData): Promise<{
|
||||
success: boolean;
|
||||
data?: Pricing;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
const newId = String(mockPricingList.length + 1);
|
||||
const newPricingNumber = `PRC-2026-${String(mockPricingList.length + 1).padStart(3, '0')}`;
|
||||
|
||||
const newPricing: Pricing = {
|
||||
...data,
|
||||
id: newId,
|
||||
pricingNumber: newPricingNumber,
|
||||
createdAt: new Date().toISOString().split('T')[0],
|
||||
};
|
||||
|
||||
// 목데이터에는 추가하지 않음 (실제 API 연동 시 DB에 저장)
|
||||
return { success: true, data: newPricing };
|
||||
const apiData = transformToApiRequest(data);
|
||||
const response = await apiClient.post<ApiPricing>('/pricing', apiData);
|
||||
return { success: true, data: transformPricing(response) };
|
||||
} catch (error) {
|
||||
console.error('[createPricing] Error:', error);
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
console.error('단가 등록 오류:', error);
|
||||
return { success: false, error: '단가 등록에 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 단가 수정 =====
|
||||
export async function updatePricing(id: string, data: Partial<Pricing>): Promise<{
|
||||
/**
|
||||
* 단가 수정
|
||||
* PUT /api/v1/pricing/{id}
|
||||
*/
|
||||
export async function updatePricing(
|
||||
id: string,
|
||||
data: Partial<PricingFormData>
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
data?: Pricing;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
const index = mockPricingList.findIndex(p => p.id === id);
|
||||
if (index === -1) {
|
||||
return { success: false, error: '단가를 찾을 수 없습니다.' };
|
||||
}
|
||||
|
||||
const updatedPricing: Pricing = {
|
||||
...mockPricingList[index],
|
||||
...data,
|
||||
updatedAt: new Date().toISOString().split('T')[0],
|
||||
};
|
||||
|
||||
// 목데이터에는 수정하지 않음 (실제 API 연동 시 DB에 업데이트)
|
||||
return { success: true, data: updatedPricing };
|
||||
const apiData = transformToApiRequest(data);
|
||||
const response = await apiClient.put<ApiPricing>(`/pricing/${id}`, apiData);
|
||||
return { success: true, data: transformPricing(response) };
|
||||
} catch (error) {
|
||||
console.error('[updatePricing] Error:', error);
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
console.error('단가 수정 오류:', error);
|
||||
return { success: false, error: '단가 수정에 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 거래처 목록 조회 (발주처) =====
|
||||
/**
|
||||
* 단가 삭제
|
||||
* DELETE /api/v1/pricing/{id}
|
||||
*/
|
||||
export async function deletePricing(id: string): Promise<{
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
await apiClient.delete(`/pricing/${id}`);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('단가 삭제 오류:', error);
|
||||
return { success: false, error: '단가 삭제에 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 단가 일괄 삭제
|
||||
* DELETE /api/v1/pricing/bulk
|
||||
*/
|
||||
export async function deletePricings(ids: string[]): Promise<{
|
||||
success: boolean;
|
||||
deletedCount?: number;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
await apiClient.delete('/pricing/bulk', {
|
||||
data: { ids: ids.map((id) => Number(id)) },
|
||||
});
|
||||
return { success: true, deletedCount: ids.length };
|
||||
} catch (error) {
|
||||
console.error('단가 일괄 삭제 오류:', error);
|
||||
return { success: false, error: '일괄 삭제에 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 거래처(벤더) 목록 조회
|
||||
* GET /api/v1/clients (거래처 API 재사용)
|
||||
*/
|
||||
export async function getVendorList(): Promise<{
|
||||
success: boolean;
|
||||
data?: { id: string; name: string }[];
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
// 목데이터에서 거래처 추출
|
||||
const vendors = [
|
||||
{ id: '1', name: '(주)슬라이드텍' },
|
||||
{ id: '2', name: '삼성전기' },
|
||||
{ id: '3', name: '현대용접산업' },
|
||||
{ id: '4', name: '철강볼트' },
|
||||
{ id: '5', name: '한국윤활유' },
|
||||
];
|
||||
const response = await apiClient.get<{
|
||||
data: ApiVendor[];
|
||||
}>('/clients', { params: { per_page: '100' } });
|
||||
|
||||
const vendors = (response.data || []).map((v) => ({
|
||||
id: String(v.id),
|
||||
name: v.name || '',
|
||||
}));
|
||||
|
||||
return { success: true, data: vendors };
|
||||
} catch (error) {
|
||||
console.error('[getVendorList] Error:', error);
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
console.error('거래처 목록 조회 오류:', error);
|
||||
return { success: false, error: '거래처 목록을 불러오는데 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 단가 확정
|
||||
* POST /api/v1/pricing/{id}/finalize
|
||||
*/
|
||||
export async function finalizePricing(id: string): Promise<{
|
||||
success: boolean;
|
||||
data?: Pricing;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
const response = await apiClient.post<ApiPricing>(`/pricing/${id}/finalize`);
|
||||
return { success: true, data: transformPricing(response) };
|
||||
} catch (error) {
|
||||
console.error('단가 확정 오류:', error);
|
||||
return { success: false, error: '단가 확정에 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 단가 변경이력 조회
|
||||
* GET /api/v1/pricing/{id}/revisions
|
||||
*/
|
||||
export async function getPricingRevisions(id: string): Promise<{
|
||||
success: boolean;
|
||||
data?: Pricing[];
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
const response = await apiClient.get<{ data: ApiPricing[] }>(`/pricing/${id}/revisions`);
|
||||
const revisions = (response.data || []).map(transformPricing);
|
||||
return { success: true, data: revisions };
|
||||
} catch (error) {
|
||||
console.error('단가 변경이력 조회 오류:', error);
|
||||
return { success: false, error: '변경이력을 불러오는데 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,44 @@ export interface PricingStats {
|
||||
notRegistered: number; // 미등록 단가
|
||||
}
|
||||
|
||||
// 목록 응답
|
||||
export interface PricingListResponse {
|
||||
items: Pricing[];
|
||||
total: number;
|
||||
page: number;
|
||||
size: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
// 필터 파라미터
|
||||
export interface PricingFilter {
|
||||
search?: string;
|
||||
status?: string;
|
||||
itemType?: string;
|
||||
category?: string;
|
||||
division?: string;
|
||||
spec?: string;
|
||||
page?: number;
|
||||
size?: number;
|
||||
sortBy?: string;
|
||||
}
|
||||
|
||||
// 폼 데이터
|
||||
export interface PricingFormData {
|
||||
itemType: string;
|
||||
category: string;
|
||||
itemName: string;
|
||||
spec: string;
|
||||
orderItems: OrderItem[];
|
||||
unit: string;
|
||||
division: string;
|
||||
vendor: string;
|
||||
purchasePrice: number;
|
||||
marginRate: number;
|
||||
sellingPrice: number;
|
||||
status: PricingStatus;
|
||||
}
|
||||
|
||||
// ===== 필터 옵션 =====
|
||||
|
||||
// 품목유형 옵션
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useState, useCallback, useMemo, useRef, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Calendar, Plus, X, Loader2, Upload, FileText, Mic, Download, List } from 'lucide-react';
|
||||
import { Calendar, Plus, X, Loader2, Upload, FileText, Mic, Download, List, Check, ChevronsUpDown } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
@@ -29,7 +29,7 @@ import { PageLayout } from '@/components/organisms/PageLayout';
|
||||
import { PageHeader } from '@/components/organisms/PageHeader';
|
||||
import { TimePicker } from '@/components/ui/time-picker';
|
||||
import { toast } from 'sonner';
|
||||
import type { SiteBriefing, SiteBriefingFormData, ParticipatingCompany, BriefingDocument } from './types';
|
||||
import type { SiteBriefing, SiteBriefingFormData, ParticipatingCompany, BriefingDocument, AttendeeItem } from './types';
|
||||
import {
|
||||
BRIEFING_TYPE_OPTIONS,
|
||||
ATTENDANCE_STATUS_OPTIONS,
|
||||
@@ -37,20 +37,28 @@ import {
|
||||
getEmptySiteBriefingFormData,
|
||||
siteBriefingToFormData,
|
||||
} from './types';
|
||||
|
||||
// 목업 거래처 목록
|
||||
const MOCK_PARTNERS = [
|
||||
{ value: '1', label: '회사명' },
|
||||
{ value: '2', label: '대한건설' },
|
||||
{ value: '3', label: '삼성시공' },
|
||||
];
|
||||
|
||||
// 목업 참석자 목록
|
||||
const MOCK_ATTENDEES = [
|
||||
{ value: 'hong', label: '홍길동' },
|
||||
{ value: 'kim', label: '김철수' },
|
||||
{ value: 'lee', label: '이영희' },
|
||||
];
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover';
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from '@/components/ui/command';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { createSiteBriefing, updateSiteBriefing, deleteSiteBriefing } from './actions';
|
||||
import { getPartnerList } from '../partners/actions';
|
||||
import { getSiteList, createSite } from '../site-management/actions';
|
||||
import { getEmployees } from '@/components/hr/EmployeeManagement/actions';
|
||||
import type { Partner } from '../partners/types';
|
||||
import type { Site } from '../site-management/types';
|
||||
import type { Employee } from '@/components/hr/EmployeeManagement/types';
|
||||
|
||||
// 목업 문서 목록 (상세 모드에서 다운로드 버튼 테스트용)
|
||||
const MOCK_DOCUMENTS: BriefingDocument[] = [
|
||||
@@ -89,10 +97,16 @@ export default function SiteBriefingForm({ mode, briefingId, initialData }: Site
|
||||
const isNewMode = mode === 'new';
|
||||
const isEditMode = mode === 'edit';
|
||||
|
||||
// DEBUG: 초기 데이터 확인
|
||||
console.log('[SiteBriefingForm] initialData:', initialData);
|
||||
console.log('[SiteBriefingForm] initialData.attendee:', initialData?.attendee);
|
||||
|
||||
// 폼 데이터
|
||||
const [formData, setFormData] = useState<SiteBriefingFormData>(
|
||||
initialData ? siteBriefingToFormData(initialData) : getEmptySiteBriefingFormData()
|
||||
);
|
||||
const [formData, setFormData] = useState<SiteBriefingFormData>(() => {
|
||||
const data = initialData ? siteBriefingToFormData(initialData) : getEmptySiteBriefingFormData();
|
||||
console.log('[SiteBriefingForm] formData.attendeeItems:', data.attendeeItems);
|
||||
return data;
|
||||
});
|
||||
|
||||
// 로딩 상태
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
@@ -107,6 +121,63 @@ export default function SiteBriefingForm({ mode, briefingId, initialData }: Site
|
||||
// 드래그 상태
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
|
||||
// 거래처 목록
|
||||
const [partners, setPartners] = useState<Partner[]>([]);
|
||||
const [isLoadingPartners, setIsLoadingPartners] = useState(false);
|
||||
|
||||
// 현장 목록 (선택된 거래처 기준)
|
||||
const [sites, setSites] = useState<Site[]>([]);
|
||||
const [isLoadingSites, setIsLoadingSites] = useState(false);
|
||||
|
||||
// 현장 입력 및 선택
|
||||
const [siteInputValue, setSiteInputValue] = useState(formData.projectName);
|
||||
const [showSiteDropdown, setShowSiteDropdown] = useState(false);
|
||||
const siteInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// 현장 신규 등록 다이얼로그
|
||||
const [showNewSiteDialog, setShowNewSiteDialog] = useState(false);
|
||||
const [newSiteName, setNewSiteName] = useState('');
|
||||
const [isCreatingSite, setIsCreatingSite] = useState(false);
|
||||
|
||||
// 직원 목록 (참석자용)
|
||||
const [employees, setEmployees] = useState<Employee[]>([]);
|
||||
|
||||
// 참석자 Multi-Select Combobox 상태
|
||||
const [attendeePopoverOpen, setAttendeePopoverOpen] = useState(false);
|
||||
const [attendeeSearchValue, setAttendeeSearchValue] = useState('');
|
||||
|
||||
// 필드 변경 핸들러 (참석자 핸들러에서 사용하므로 먼저 선언)
|
||||
const handleChange = useCallback((field: keyof SiteBriefingFormData, value: unknown) => {
|
||||
setFormData((prev) => ({ ...prev, [field]: value }));
|
||||
}, []);
|
||||
|
||||
// 참석자 선택 핸들러
|
||||
const handleAttendeeSelect = useCallback((employee: Employee) => {
|
||||
const newItem: AttendeeItem = { id: employee.id, name: employee.name };
|
||||
const exists = formData.attendeeItems.some((item) => item.id === employee.id);
|
||||
if (!exists) {
|
||||
handleChange('attendeeItems', [...formData.attendeeItems, newItem]);
|
||||
}
|
||||
setAttendeeSearchValue('');
|
||||
}, [formData.attendeeItems, handleChange]);
|
||||
|
||||
// 참석자 제거 핸들러
|
||||
const handleAttendeeRemove = useCallback((attendeeId: string) => {
|
||||
handleChange('attendeeItems', formData.attendeeItems.filter((item) => item.id !== attendeeId && item.name !== attendeeId));
|
||||
}, [formData.attendeeItems, handleChange]);
|
||||
|
||||
// 참석자 직접 입력 추가 핸들러
|
||||
const handleAttendeeAdd = useCallback(() => {
|
||||
const trimmed = attendeeSearchValue.trim();
|
||||
if (!trimmed) return;
|
||||
const exists = formData.attendeeItems.some((item) => item.name === trimmed);
|
||||
if (!exists) {
|
||||
const newItem: AttendeeItem = { id: '', name: trimmed };
|
||||
handleChange('attendeeItems', [...formData.attendeeItems, newItem]);
|
||||
}
|
||||
setAttendeeSearchValue('');
|
||||
}, [attendeeSearchValue, formData.attendeeItems, handleChange]);
|
||||
|
||||
// 상세/수정 모드에서 목데이터 초기화
|
||||
useEffect(() => {
|
||||
if (initialData && formData.documents.length === 0) {
|
||||
@@ -117,9 +188,66 @@ export default function SiteBriefingForm({ mode, briefingId, initialData }: Site
|
||||
}
|
||||
}, [initialData]);
|
||||
|
||||
// 필드 변경 핸들러
|
||||
const handleChange = useCallback((field: keyof SiteBriefingFormData, value: unknown) => {
|
||||
setFormData((prev) => ({ ...prev, [field]: value }));
|
||||
// 거래처 목록 로드
|
||||
useEffect(() => {
|
||||
const loadPartners = async () => {
|
||||
setIsLoadingPartners(true);
|
||||
try {
|
||||
const result = await getPartnerList({ size: 100 });
|
||||
if (result.success && result.data) {
|
||||
setPartners(result.data.items);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('거래처 목록 로드 실패:', error);
|
||||
} finally {
|
||||
setIsLoadingPartners(false);
|
||||
}
|
||||
};
|
||||
loadPartners();
|
||||
}, []);
|
||||
|
||||
// 거래처 선택 시 현장 목록 로드
|
||||
useEffect(() => {
|
||||
const loadSites = async () => {
|
||||
if (!formData.partnerId) {
|
||||
setSites([]);
|
||||
return;
|
||||
}
|
||||
setIsLoadingSites(true);
|
||||
try {
|
||||
const result = await getSiteList({ clientId: formData.partnerId, size: 100 });
|
||||
if (result.success && result.data) {
|
||||
setSites(result.data.items);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('현장 목록 로드 실패:', error);
|
||||
} finally {
|
||||
setIsLoadingSites(false);
|
||||
}
|
||||
};
|
||||
loadSites();
|
||||
}, [formData.partnerId]);
|
||||
|
||||
// 초기 데이터가 있을 때 siteInputValue 동기화
|
||||
useEffect(() => {
|
||||
if (initialData?.title) {
|
||||
setSiteInputValue(initialData.title);
|
||||
}
|
||||
}, [initialData]);
|
||||
|
||||
// 직원 목록 로드 (참석자용)
|
||||
useEffect(() => {
|
||||
const loadEmployees = async () => {
|
||||
try {
|
||||
const result = await getEmployees({ status: 'active', per_page: 100 });
|
||||
if (result.data && result.data.length > 0) {
|
||||
setEmployees(result.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('직원 목록 로드 실패:', error);
|
||||
}
|
||||
};
|
||||
loadEmployees();
|
||||
}, []);
|
||||
|
||||
// 네비게이션 핸들러
|
||||
@@ -151,8 +279,19 @@ export default function SiteBriefingForm({ mode, briefingId, initialData }: Site
|
||||
const handleConfirmSave = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
// TODO: 실제 API 연동
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
let result;
|
||||
if (isNewMode) {
|
||||
result = await createSiteBriefing(formData);
|
||||
} else if (briefingId) {
|
||||
result = await updateSiteBriefing(briefingId, formData);
|
||||
} else {
|
||||
throw new Error('현장설명회 ID가 없습니다.');
|
||||
}
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || '저장에 실패했습니다.');
|
||||
}
|
||||
|
||||
toast.success(isNewMode ? '현장설명회가 등록되었습니다.' : '수정이 완료되었습니다.');
|
||||
setShowSaveDialog(false);
|
||||
router.push('/ko/construction/project/bidding/site-briefings');
|
||||
@@ -162,7 +301,7 @@ export default function SiteBriefingForm({ mode, briefingId, initialData }: Site
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [isNewMode, router]);
|
||||
}, [isNewMode, briefingId, formData, router]);
|
||||
|
||||
// 삭제 핸들러
|
||||
const handleDelete = useCallback(() => {
|
||||
@@ -170,10 +309,19 @@ export default function SiteBriefingForm({ mode, briefingId, initialData }: Site
|
||||
}, []);
|
||||
|
||||
const handleConfirmDelete = useCallback(async () => {
|
||||
if (!briefingId) {
|
||||
toast.error('현장설명회 ID가 없습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
// TODO: 실제 API 연동
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
const result = await deleteSiteBriefing(briefingId);
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || '삭제에 실패했습니다.');
|
||||
}
|
||||
|
||||
toast.success('현장설명회가 삭제되었습니다.');
|
||||
setShowDeleteDialog(false);
|
||||
router.push('/ko/construction/project/bidding/site-briefings');
|
||||
@@ -183,7 +331,51 @@ export default function SiteBriefingForm({ mode, briefingId, initialData }: Site
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [router]);
|
||||
}, [briefingId, router]);
|
||||
|
||||
// 현장 신규 등록 핸들러
|
||||
const handleCreateSite = useCallback(async () => {
|
||||
if (!newSiteName.trim()) {
|
||||
toast.error('현장명을 입력해주세요.');
|
||||
return;
|
||||
}
|
||||
if (!formData.partnerId) {
|
||||
toast.error('거래처를 먼저 선택해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsCreatingSite(true);
|
||||
try {
|
||||
const result = await createSite({
|
||||
siteName: newSiteName.trim(),
|
||||
partnerId: formData.partnerId,
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || '현장 등록에 실패했습니다.');
|
||||
}
|
||||
|
||||
toast.success('현장이 등록되었습니다.');
|
||||
|
||||
// 현장 목록 새로고침
|
||||
const sitesResult = await getSiteList({ clientId: formData.partnerId, size: 100 });
|
||||
if (sitesResult.success && sitesResult.data) {
|
||||
setSites(sitesResult.data.items);
|
||||
}
|
||||
|
||||
// 새로 등록된 현장을 선택
|
||||
setSiteInputValue(newSiteName.trim());
|
||||
handleChange('projectName', newSiteName.trim());
|
||||
|
||||
// 다이얼로그 닫기 및 상태 초기화
|
||||
setShowNewSiteDialog(false);
|
||||
setNewSiteName('');
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : '현장 등록에 실패했습니다.');
|
||||
} finally {
|
||||
setIsCreatingSite(false);
|
||||
}
|
||||
}, [newSiteName, formData.partnerId, handleChange]);
|
||||
|
||||
// 참여업체 추가 핸들러
|
||||
const handleAddCompany = useCallback(() => {
|
||||
@@ -451,7 +643,38 @@ export default function SiteBriefingForm({ mode, briefingId, initialData }: Site
|
||||
{renderField('현설번호', 'briefingCode', formData.briefingCode, {
|
||||
placeholder: '123123',
|
||||
})}
|
||||
{renderSelectField('거래처명', 'partnerId', formData.partnerId, MOCK_PARTNERS, true)}
|
||||
{/* 거래처명 - 실제 API 데이터 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium text-gray-700">
|
||||
거래처명 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Select
|
||||
value={formData.partnerId}
|
||||
onValueChange={(val) => {
|
||||
handleChange('partnerId', val);
|
||||
// 거래처 변경 시 현장명 초기화
|
||||
handleChange('projectName', '');
|
||||
setSiteInputValue('');
|
||||
// partnerName도 업데이트
|
||||
const selectedPartner = partners.find((p) => p.id === val);
|
||||
if (selectedPartner) {
|
||||
handleChange('partnerName', selectedPartner.partnerName);
|
||||
}
|
||||
}}
|
||||
disabled={isViewMode || isLoadingPartners}
|
||||
>
|
||||
<SelectTrigger className="bg-white">
|
||||
<SelectValue placeholder={isLoadingPartners ? '로딩 중...' : '거래처 선택'} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{partners.map((partner) => (
|
||||
<SelectItem key={partner.id} value={partner.id}>
|
||||
{partner.partnerName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{renderField('현장설명회 일자', 'briefingDate', formData.briefingDate, {
|
||||
type: 'date',
|
||||
required: true,
|
||||
@@ -471,7 +694,112 @@ export default function SiteBriefingForm({ mode, briefingId, initialData }: Site
|
||||
{renderField('현장설명회 장소', 'location', formData.location, {
|
||||
placeholder: '장소명',
|
||||
})}
|
||||
{renderSelectField('참석자', 'attendee', formData.attendee, MOCK_ATTENDEES)}
|
||||
{/* 참석자 - Multi-Select Combobox */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium text-gray-700">참석자</Label>
|
||||
{isViewMode ? (
|
||||
<div className="flex flex-wrap gap-2 min-h-[38px] p-2 border rounded-md bg-gray-50">
|
||||
{formData.attendeeItems.length > 0 ? (
|
||||
formData.attendeeItems.map((item) => (
|
||||
<Badge key={item.id || item.name} variant="secondary">
|
||||
{item.name}
|
||||
</Badge>
|
||||
))
|
||||
) : (
|
||||
<span className="text-gray-400 text-sm">참석자 없음</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<Popover open={attendeePopoverOpen} onOpenChange={setAttendeePopoverOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={attendeePopoverOpen}
|
||||
className="w-full justify-between min-h-[38px] h-auto"
|
||||
>
|
||||
<div className="flex flex-wrap gap-1 flex-1 text-left">
|
||||
{formData.attendeeItems.length > 0 ? (
|
||||
formData.attendeeItems.map((item) => (
|
||||
<Badge
|
||||
key={item.id || item.name}
|
||||
variant="secondary"
|
||||
className="mr-1"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleAttendeeRemove(item.id || item.name);
|
||||
}}
|
||||
>
|
||||
{item.name}
|
||||
<X className="ml-1 h-3 w-3 cursor-pointer" />
|
||||
</Badge>
|
||||
))
|
||||
) : (
|
||||
<span className="text-muted-foreground">참석자 선택 또는 직접 입력</span>
|
||||
)}
|
||||
</div>
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-full p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput
|
||||
placeholder="직원 검색 또는 직접 입력..."
|
||||
value={attendeeSearchValue}
|
||||
onValueChange={setAttendeeSearchValue}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && attendeeSearchValue.trim()) {
|
||||
e.preventDefault();
|
||||
handleAttendeeAdd();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
{attendeeSearchValue.trim() ? (
|
||||
<button
|
||||
type="button"
|
||||
className="w-full px-2 py-1.5 text-sm text-left hover:bg-accent rounded"
|
||||
onClick={handleAttendeeAdd}
|
||||
>
|
||||
"{attendeeSearchValue}" 추가
|
||||
</button>
|
||||
) : (
|
||||
'검색 결과가 없습니다.'
|
||||
)}
|
||||
</CommandEmpty>
|
||||
<CommandGroup heading="직원 목록">
|
||||
{employees
|
||||
.filter((emp) =>
|
||||
emp.name.toLowerCase().includes(attendeeSearchValue.toLowerCase())
|
||||
)
|
||||
.map((employee) => {
|
||||
const isSelected = formData.attendeeItems.some(
|
||||
(item) => item.id === employee.id
|
||||
);
|
||||
return (
|
||||
<CommandItem
|
||||
key={employee.id}
|
||||
value={employee.name}
|
||||
onSelect={() => handleAttendeeSelect(employee)}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
'mr-2 h-4 w-4',
|
||||
isSelected ? 'opacity-100' : 'opacity-0'
|
||||
)}
|
||||
/>
|
||||
{employee.name}
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
</div>
|
||||
{renderSelectField('상태', 'attendanceStatus', formData.attendanceStatus, ATTENDANCE_STATUS_OPTIONS)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -483,10 +811,87 @@ export default function SiteBriefingForm({ mode, briefingId, initialData }: Site
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{renderField('현장명', 'projectName', formData.projectName, {
|
||||
required: true,
|
||||
placeholder: '현장명',
|
||||
})}
|
||||
{/* 현장명 - 거래처 연동 Combobox */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium text-gray-700">
|
||||
현장명 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
ref={siteInputRef}
|
||||
type="text"
|
||||
value={siteInputValue}
|
||||
onChange={(e) => {
|
||||
setSiteInputValue(e.target.value);
|
||||
handleChange('projectName', e.target.value);
|
||||
setShowSiteDropdown(true);
|
||||
}}
|
||||
onFocus={() => formData.partnerId && setShowSiteDropdown(true)}
|
||||
onBlur={() => setTimeout(() => setShowSiteDropdown(false), 200)}
|
||||
placeholder={
|
||||
!formData.partnerId
|
||||
? '거래처를 먼저 선택해주세요'
|
||||
: isLoadingSites
|
||||
? '현장 목록 로딩 중...'
|
||||
: '현장명 입력 또는 선택'
|
||||
}
|
||||
disabled={isViewMode || !formData.partnerId}
|
||||
className="bg-white pr-10"
|
||||
/>
|
||||
{!isViewMode && formData.partnerId && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute right-1 top-1/2 -translate-y-1/2 h-7 w-7"
|
||||
onClick={() => {
|
||||
setNewSiteName(siteInputValue);
|
||||
setShowNewSiteDialog(true);
|
||||
}}
|
||||
title="현장 신규 등록"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
{/* 현장 드롭다운 */}
|
||||
{showSiteDropdown && sites.length > 0 && (
|
||||
<div className="absolute z-50 w-full mt-1 bg-white border rounded-md shadow-lg max-h-60 overflow-auto">
|
||||
{sites
|
||||
.filter((site) =>
|
||||
site.siteName.toLowerCase().includes(siteInputValue.toLowerCase())
|
||||
)
|
||||
.map((site) => (
|
||||
<button
|
||||
key={site.id}
|
||||
type="button"
|
||||
className="w-full px-3 py-2 text-left text-sm hover:bg-gray-100 focus:bg-gray-100 focus:outline-none"
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
setSiteInputValue(site.siteName);
|
||||
handleChange('projectName', site.siteName);
|
||||
setShowSiteDropdown(false);
|
||||
}}
|
||||
>
|
||||
<div className="font-medium">{site.siteName}</div>
|
||||
{site.address && (
|
||||
<div className="text-xs text-gray-500">{site.address}</div>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
{sites.filter((site) =>
|
||||
site.siteName.toLowerCase().includes(siteInputValue.toLowerCase())
|
||||
).length === 0 && (
|
||||
<div className="px-3 py-2 text-sm text-gray-500">
|
||||
일치하는 현장이 없습니다. 신규 등록하려면 + 버튼을 클릭하세요.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{!formData.partnerId && !isViewMode && (
|
||||
<p className="text-xs text-amber-600">거래처를 먼저 선택하면 해당 거래처의 현장 목록이 표시됩니다.</p>
|
||||
)}
|
||||
</div>
|
||||
{renderField('입찰일자', 'bidDate', formData.bidDate, {
|
||||
type: 'date',
|
||||
})}
|
||||
@@ -736,6 +1141,53 @@ export default function SiteBriefingForm({ mode, briefingId, initialData }: Site
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{/* 현장 신규 등록 다이얼로그 */}
|
||||
<AlertDialog open={showNewSiteDialog} onOpenChange={setShowNewSiteDialog}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>현장 신규 등록</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
선택한 거래처에 새로운 현장을 등록합니다.
|
||||
<br />
|
||||
등록된 현장은 현장관리 목록에도 추가됩니다.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<div className="py-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">거래처</Label>
|
||||
<Input
|
||||
value={partners.find((p) => p.id === formData.partnerId)?.partnerName || ''}
|
||||
disabled
|
||||
className="bg-gray-50"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2 mt-4">
|
||||
<Label className="text-sm font-medium">
|
||||
현장명 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
value={newSiteName}
|
||||
onChange={(e) => setNewSiteName(e.target.value)}
|
||||
placeholder="현장명을 입력하세요"
|
||||
disabled={isCreatingSite}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isCreatingSite}>취소</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleCreateSite}
|
||||
className="bg-blue-500 hover:bg-blue-600"
|
||||
disabled={isCreatingSite || !newSiteName.trim()}
|
||||
>
|
||||
{isCreatingSite && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
|
||||
등록
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
@@ -130,12 +130,11 @@ export default function SiteBriefingListClient({ initialData = [] }: SiteBriefin
|
||||
|
||||
if (listResult.success && listResult.data) {
|
||||
setBriefings(listResult.data.items);
|
||||
// 목업 통계 계산 (참석 상태 기준)
|
||||
// 통계 계산 (참석 상태 기준)
|
||||
const items = listResult.data.items;
|
||||
const total = items.length;
|
||||
// 목업: scheduled 상태는 참석예정, 나머지는 참석완료로 처리
|
||||
const scheduled = items.filter((b) => b.status === 'scheduled').length;
|
||||
const attended = items.filter((b) => b.status !== 'scheduled').length;
|
||||
const scheduled = items.filter((b) => b.attendanceStatus === 'scheduled').length;
|
||||
const attended = items.filter((b) => b.attendanceStatus === 'attended').length;
|
||||
setStatsData({ total, scheduled, attended });
|
||||
}
|
||||
} catch {
|
||||
@@ -155,9 +154,9 @@ export default function SiteBriefingListClient({ initialData = [] }: SiteBriefin
|
||||
// 필터링된 데이터
|
||||
const filteredBriefings = useMemo(() => {
|
||||
return briefings.filter((briefing) => {
|
||||
// Stats 탭 필터
|
||||
if (activeStatTab === 'scheduled' && briefing.status !== 'scheduled') return false;
|
||||
if (activeStatTab === 'attended' && briefing.status === 'scheduled') return false;
|
||||
// Stats 탭 필터 (참석 상태 기준)
|
||||
if (activeStatTab === 'scheduled' && briefing.attendanceStatus !== 'scheduled') return false;
|
||||
if (activeStatTab === 'attended' && briefing.attendanceStatus !== 'attended') return false;
|
||||
|
||||
// 거래처 필터 (다중선택 - 빈 배열 = 전체)
|
||||
if (partnerFilters.length > 0) {
|
||||
@@ -174,8 +173,8 @@ export default function SiteBriefingListClient({ initialData = [] }: SiteBriefin
|
||||
if (!attendeeFilters.includes(attendeeId)) return false;
|
||||
}
|
||||
|
||||
// 상태 필터
|
||||
if (statusFilter !== 'all' && briefing.status !== statusFilter) return false;
|
||||
// 상태 필터 (참석 상태 기준)
|
||||
if (statusFilter !== 'all' && briefing.attendanceStatus !== statusFilter) return false;
|
||||
|
||||
// 검색 필터
|
||||
if (searchValue) {
|
||||
@@ -431,8 +430,8 @@ export default function SiteBriefingListClient({ initialData = [] }: SiteBriefin
|
||||
const renderTableRow = useCallback(
|
||||
(briefing: SiteBriefing, index: number, globalIndex: number) => {
|
||||
const isSelected = selectedItems.has(briefing.id);
|
||||
// 목업 데이터에서 상태 매핑
|
||||
const displayStatus = briefing.status === 'scheduled' ? 'scheduled' : 'attended';
|
||||
// 참석 상태 표시
|
||||
const displayStatus = briefing.attendanceStatus || 'scheduled';
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
@@ -490,7 +489,7 @@ export default function SiteBriefingListClient({ initialData = [] }: SiteBriefin
|
||||
// 모바일 카드 렌더링
|
||||
const renderMobileCard = useCallback(
|
||||
(briefing: SiteBriefing, index: number, globalIndex: number, isSelected: boolean, onToggle: () => void) => {
|
||||
const displayStatus = briefing.status === 'scheduled' ? 'scheduled' : 'attended';
|
||||
const displayStatus = briefing.attendanceStatus || 'scheduled';
|
||||
|
||||
return (
|
||||
<MobileCard
|
||||
|
||||
@@ -1,188 +1,189 @@
|
||||
'use server';
|
||||
|
||||
import type { SiteBriefing, SiteBriefingStats, SiteBriefingFilter, SiteBriefingListResponse } from './types';
|
||||
import type { SiteBriefing, SiteBriefingStats, SiteBriefingFilter, SiteBriefingListResponse, SiteBriefingFormData } from './types';
|
||||
import { apiClient } from '@/lib/api';
|
||||
|
||||
/**
|
||||
* 주일 기업 - 현장설명회 관리 Server Actions
|
||||
* TODO: 실제 API 연동 시 구현
|
||||
* 표준화된 apiClient 사용 버전
|
||||
*/
|
||||
|
||||
// 목업 데이터
|
||||
const mockSiteBriefings: SiteBriefing[] = [
|
||||
{
|
||||
id: '1',
|
||||
briefingCode: 'SB-001',
|
||||
title: '강남 오피스텔 신축공사',
|
||||
description: '강남구 삼성동 오피스텔 신축 현장설명회',
|
||||
partnerId: '1',
|
||||
partnerName: '대한건설',
|
||||
briefingDate: '2025-05-12',
|
||||
briefingTime: '14:00',
|
||||
location: '강남구청 대회의실',
|
||||
address: '서울특별시 강남구 학동로 426',
|
||||
status: 'scheduled',
|
||||
bidStatus: 'pending',
|
||||
bidDate: '2025-05-15',
|
||||
// ========================================
|
||||
// API 응답 타입
|
||||
// ========================================
|
||||
|
||||
interface ApiSiteBriefing {
|
||||
id: number;
|
||||
briefing_code: string | null;
|
||||
title: string;
|
||||
description: string | null;
|
||||
partner_id: number | null;
|
||||
partner_name: string | null;
|
||||
briefing_date: string;
|
||||
briefing_time: string | null;
|
||||
briefing_type: string | null;
|
||||
location: string | null;
|
||||
address: string | null;
|
||||
status: string | null;
|
||||
bid_status: string | null;
|
||||
bid_date: string | null;
|
||||
attendees: Array<{ id: string; name: string }> | null; // 백엔드는 attendees (복수형), array 타입
|
||||
attendance_status: string | null;
|
||||
project_name: string | null;
|
||||
site_count: number | null;
|
||||
construction_start_date: string | null;
|
||||
construction_end_date: string | null;
|
||||
vat_type: string | null;
|
||||
work_report: string | null;
|
||||
attendee_count: number | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
created_by: string | null;
|
||||
}
|
||||
|
||||
interface ApiSiteBriefingStats {
|
||||
total: number;
|
||||
scheduled: number;
|
||||
ongoing: number;
|
||||
completed: number;
|
||||
cancelled: number;
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 타입 변환 함수
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* API 응답 → SiteBriefing 타입 변환
|
||||
*/
|
||||
function transformSiteBriefing(apiData: ApiSiteBriefing): SiteBriefing {
|
||||
// attendees를 JSON 문자열로 변환 (types.ts의 parseAttendeeItems에서 파싱)
|
||||
const attendeeJson = apiData.attendees ? JSON.stringify(apiData.attendees) : '';
|
||||
|
||||
return {
|
||||
id: String(apiData.id),
|
||||
briefingCode: apiData.briefing_code || '',
|
||||
title: apiData.title || apiData.project_name || '',
|
||||
description: apiData.description || '',
|
||||
partnerId: apiData.partner_id ? String(apiData.partner_id) : '',
|
||||
partnerName: apiData.partner_name || '',
|
||||
briefingDate: apiData.briefing_date || '',
|
||||
briefingTime: apiData.briefing_time || '',
|
||||
location: apiData.location || '',
|
||||
address: apiData.address || '',
|
||||
status: (apiData.status as SiteBriefing['status']) || 'scheduled',
|
||||
bidStatus: (apiData.bid_status as SiteBriefing['bidStatus']) || 'pending',
|
||||
bidDate: apiData.bid_date,
|
||||
attendee: attendeeJson, // JSON 문자열로 저장 (parseAttendeeItems에서 파싱)
|
||||
attendees: [],
|
||||
attendeeCount: 5,
|
||||
createdAt: '2025-01-01',
|
||||
updatedAt: '2025-01-01',
|
||||
createdBy: '홍길동',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
briefingCode: 'SB-002',
|
||||
title: '서초 아파트 리모델링',
|
||||
description: '서초구 반포동 아파트 리모델링 현장설명회',
|
||||
partnerId: '2',
|
||||
partnerName: '삼성시공',
|
||||
briefingDate: '2025-05-12',
|
||||
briefingTime: '10:00',
|
||||
location: '서초구청 소회의실',
|
||||
address: '서울특별시 서초구 남부순환로 2584',
|
||||
status: 'ongoing',
|
||||
bidStatus: 'bidding',
|
||||
bidDate: '2025-05-18',
|
||||
attendees: [],
|
||||
attendeeCount: 8,
|
||||
createdAt: '2025-01-02',
|
||||
updatedAt: '2025-01-02',
|
||||
createdBy: '김철수',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
briefingCode: 'SB-003',
|
||||
title: '여의도 상업시설 신축',
|
||||
description: '영등포구 여의도동 상업시설 신축 현장설명회',
|
||||
partnerId: '3',
|
||||
partnerName: 'LG건설',
|
||||
briefingDate: '2025-05-13',
|
||||
briefingTime: '15:00',
|
||||
location: 'LG트윈타워 회의실',
|
||||
address: '서울특별시 영등포구 여의대로 128',
|
||||
status: 'completed',
|
||||
bidStatus: 'awarded',
|
||||
bidDate: '2025-05-20',
|
||||
attendees: [],
|
||||
attendeeCount: 12,
|
||||
createdAt: '2025-01-03',
|
||||
updatedAt: '2025-01-03',
|
||||
createdBy: '박영수',
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
briefingCode: 'SB-004',
|
||||
title: '송파 주상복합 공사',
|
||||
description: '송파구 잠실동 주상복합 건축 현장설명회',
|
||||
partnerId: '1',
|
||||
partnerName: '대한건설',
|
||||
briefingDate: '2025-05-14',
|
||||
briefingTime: '11:00',
|
||||
location: '롯데월드타워 회의실',
|
||||
address: '서울특별시 송파구 올림픽로 300',
|
||||
status: 'cancelled',
|
||||
bidStatus: 'failed',
|
||||
bidDate: null,
|
||||
attendees: [],
|
||||
attendeeCount: 0,
|
||||
createdAt: '2025-01-04',
|
||||
updatedAt: '2025-01-04',
|
||||
createdBy: '최민수',
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
briefingCode: 'SB-005',
|
||||
title: '마포 물류센터 증축',
|
||||
description: '마포구 상암동 물류센터 증축 현장설명회',
|
||||
partnerId: '2',
|
||||
partnerName: '삼성시공',
|
||||
briefingDate: '2025-05-15',
|
||||
briefingTime: '09:00',
|
||||
location: '상암 DMC 회의실',
|
||||
address: '서울특별시 마포구 상암산로 76',
|
||||
status: 'postponed',
|
||||
bidStatus: 'pending',
|
||||
bidDate: null,
|
||||
attendees: [],
|
||||
attendeeCount: 3,
|
||||
createdAt: '2025-01-05',
|
||||
updatedAt: '2025-01-05',
|
||||
createdBy: '이영희',
|
||||
},
|
||||
];
|
||||
attendeeCount: apiData.attendee_count || 0,
|
||||
attendanceStatus: (apiData.attendance_status as SiteBriefing['attendanceStatus']) || 'scheduled',
|
||||
createdAt: apiData.created_at || '',
|
||||
updatedAt: apiData.updated_at || '',
|
||||
createdBy: apiData.created_by || '',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* SiteBriefingFormData → API 요청 데이터 변환
|
||||
*/
|
||||
function transformFormDataToApi(data: SiteBriefingFormData): Record<string, unknown> {
|
||||
// attendeeItems 배열을 백엔드 형식으로 변환
|
||||
// - id가 있으면 internal (직원), 없으면 external (외부인/직접입력)
|
||||
const attendees = data.attendeeItems && data.attendeeItems.length > 0
|
||||
? data.attendeeItems.map(item => ({
|
||||
...item,
|
||||
type: item.id ? 'internal' : 'external',
|
||||
}))
|
||||
: null;
|
||||
|
||||
return {
|
||||
briefing_code: data.briefingCode,
|
||||
title: data.projectName,
|
||||
description: data.workReport,
|
||||
partner_id: data.partnerId ? Number(data.partnerId) : null,
|
||||
partner_name: data.partnerName,
|
||||
briefing_date: data.briefingDate,
|
||||
briefing_time: data.briefingTime,
|
||||
briefing_type: data.briefingType,
|
||||
location: data.location,
|
||||
attendees: attendees, // 백엔드 필드명: attendees (복수형, array)
|
||||
attendance_status: data.attendanceStatus,
|
||||
project_name: data.projectName,
|
||||
bid_date: data.bidDate,
|
||||
site_count: data.siteCount,
|
||||
construction_start_date: data.constructionStartDate,
|
||||
construction_end_date: data.constructionEndDate,
|
||||
vat_type: data.vatType,
|
||||
work_report: data.workReport,
|
||||
};
|
||||
}
|
||||
|
||||
// 현장설명회 목록 조회
|
||||
export async function getSiteBriefingList(
|
||||
filter?: SiteBriefingFilter
|
||||
): Promise<{ success: boolean; data?: SiteBriefingListResponse; error?: string }> {
|
||||
try {
|
||||
let filtered = [...mockSiteBriefings];
|
||||
// API 쿼리 파라미터 구성 (모든 값을 문자열로 변환)
|
||||
const params: Record<string, string> = {
|
||||
per_page: String(filter?.size ?? 20),
|
||||
page: String(filter?.page ?? 1),
|
||||
};
|
||||
|
||||
// 검색 필터
|
||||
if (filter?.search) {
|
||||
const search = filter.search.toLowerCase();
|
||||
filtered = filtered.filter(
|
||||
(b) =>
|
||||
b.title.toLowerCase().includes(search) ||
|
||||
b.briefingCode.toLowerCase().includes(search) ||
|
||||
b.partnerName.toLowerCase().includes(search)
|
||||
);
|
||||
params.search = filter.search;
|
||||
}
|
||||
|
||||
// 상태 필터
|
||||
if (filter?.status && filter.status !== 'all') {
|
||||
filtered = filtered.filter((b) => b.status === filter.status);
|
||||
params.status = filter.status;
|
||||
}
|
||||
|
||||
// 입찰 상태 필터
|
||||
if (filter?.bidStatus && filter.bidStatus !== 'all') {
|
||||
filtered = filtered.filter((b) => b.bidStatus === filter.bidStatus);
|
||||
params.bid_status = filter.bidStatus;
|
||||
}
|
||||
|
||||
// 거래처 필터
|
||||
if (filter?.partnerId) {
|
||||
filtered = filtered.filter((b) => b.partnerId === filter.partnerId);
|
||||
params.partner_id = filter.partnerId;
|
||||
}
|
||||
|
||||
// 날짜 필터
|
||||
if (filter?.startDate) {
|
||||
filtered = filtered.filter((b) => b.briefingDate >= filter.startDate!);
|
||||
params.start_date = filter.startDate;
|
||||
}
|
||||
if (filter?.endDate) {
|
||||
filtered = filtered.filter((b) => b.briefingDate <= filter.endDate!);
|
||||
params.end_date = filter.endDate;
|
||||
}
|
||||
|
||||
// 정렬
|
||||
if (filter?.sortBy) {
|
||||
switch (filter.sortBy) {
|
||||
case 'latest':
|
||||
filtered.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
||||
break;
|
||||
case 'oldest':
|
||||
filtered.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
|
||||
break;
|
||||
case 'dateAsc':
|
||||
filtered.sort((a, b) => new Date(a.briefingDate).getTime() - new Date(b.briefingDate).getTime());
|
||||
break;
|
||||
case 'dateDesc':
|
||||
filtered.sort((a, b) => new Date(b.briefingDate).getTime() - new Date(a.briefingDate).getTime());
|
||||
break;
|
||||
const sortMapping: Record<string, { sort_by: string; sort_dir: string }> = {
|
||||
latest: { sort_by: 'created_at', sort_dir: 'desc' },
|
||||
oldest: { sort_by: 'created_at', sort_dir: 'asc' },
|
||||
dateAsc: { sort_by: 'briefing_date', sort_dir: 'asc' },
|
||||
dateDesc: { sort_by: 'briefing_date', sort_dir: 'desc' },
|
||||
};
|
||||
const sort = sortMapping[filter.sortBy];
|
||||
if (sort) {
|
||||
params.sort_by = sort.sort_by;
|
||||
params.sort_dir = sort.sort_dir;
|
||||
}
|
||||
}
|
||||
|
||||
const page = filter?.page ?? 1;
|
||||
const size = filter?.size ?? 20;
|
||||
const start = (page - 1) * size;
|
||||
const paginatedItems = filtered.slice(start, start + size);
|
||||
const response = await apiClient.get<{
|
||||
success: boolean;
|
||||
data: {
|
||||
data: ApiSiteBriefing[];
|
||||
current_page: number;
|
||||
per_page: number;
|
||||
total: number;
|
||||
last_page: number;
|
||||
};
|
||||
}>('/site-briefings', { params });
|
||||
|
||||
const apiData = response.data;
|
||||
const items = apiData.data.map(transformSiteBriefing);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
items: paginatedItems,
|
||||
total: filtered.length,
|
||||
page,
|
||||
size,
|
||||
totalPages: Math.ceil(filtered.length / size),
|
||||
items,
|
||||
total: apiData.total,
|
||||
page: apiData.current_page,
|
||||
size: apiData.per_page,
|
||||
totalPages: apiData.last_page,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
@@ -196,13 +197,12 @@ export async function getSiteBriefing(
|
||||
id: string
|
||||
): Promise<{ success: boolean; data?: SiteBriefing; error?: string }> {
|
||||
try {
|
||||
const briefing = mockSiteBriefings.find((b) => b.id === id);
|
||||
const response = await apiClient.get<{
|
||||
success: boolean;
|
||||
data: ApiSiteBriefing;
|
||||
}>(`/site-briefings/${id}`);
|
||||
|
||||
if (!briefing) {
|
||||
return { success: false, error: '현장설명회를 찾을 수 없습니다.' };
|
||||
}
|
||||
|
||||
return { success: true, data: briefing };
|
||||
return { success: true, data: transformSiteBriefing(response.data) };
|
||||
} catch (error) {
|
||||
console.error('getSiteBriefing error:', error);
|
||||
return { success: false, error: '현장설명회 조회에 실패했습니다.' };
|
||||
@@ -212,46 +212,86 @@ export async function getSiteBriefing(
|
||||
// 현장설명회 통계 조회
|
||||
export async function getSiteBriefingStats(): Promise<{ success: boolean; data?: SiteBriefingStats; error?: string }> {
|
||||
try {
|
||||
const total = mockSiteBriefings.length;
|
||||
const scheduled = mockSiteBriefings.filter((b) => b.status === 'scheduled').length;
|
||||
const ongoing = mockSiteBriefings.filter((b) => b.status === 'ongoing').length;
|
||||
const completed = mockSiteBriefings.filter((b) => b.status === 'completed').length;
|
||||
const cancelled = mockSiteBriefings.filter((b) => b.status === 'cancelled' || b.status === 'postponed').length;
|
||||
const response = await apiClient.get<{
|
||||
success: boolean;
|
||||
data: ApiSiteBriefingStats;
|
||||
}>('/site-briefings/stats');
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
total,
|
||||
scheduled,
|
||||
ongoing,
|
||||
completed,
|
||||
cancelled,
|
||||
},
|
||||
};
|
||||
return { success: true, data: response.data };
|
||||
} catch (error) {
|
||||
console.error('getSiteBriefingStats error:', error);
|
||||
return { success: false, error: '통계 조회에 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// 현장설명회 삭제
|
||||
// ========================================
|
||||
// API 함수 (CRUD)
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* 현장설명회 등록
|
||||
* POST /api/v1/site-briefings
|
||||
*/
|
||||
export async function createSiteBriefing(data: SiteBriefingFormData): Promise<{
|
||||
success: boolean;
|
||||
data?: SiteBriefing;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
const apiData = transformFormDataToApi(data);
|
||||
const response = await apiClient.post<{ success: boolean; data: ApiSiteBriefing }>('/site-briefings', apiData);
|
||||
return { success: true, data: transformSiteBriefing(response.data) };
|
||||
} catch (error) {
|
||||
console.error('현장설명회 등록 오류:', error);
|
||||
return { success: false, error: '현장설명회 등록에 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 현장설명회 수정
|
||||
* PUT /api/v1/site-briefings/{id}
|
||||
*/
|
||||
export async function updateSiteBriefing(id: string, data: SiteBriefingFormData): Promise<{
|
||||
success: boolean;
|
||||
data?: SiteBriefing;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
const apiData = transformFormDataToApi(data);
|
||||
const response = await apiClient.put<{ success: boolean; data: ApiSiteBriefing }>(`/site-briefings/${id}`, apiData);
|
||||
return { success: true, data: transformSiteBriefing(response.data) };
|
||||
} catch (error) {
|
||||
console.error('현장설명회 수정 오류:', error);
|
||||
return { success: false, error: '현장설명회 수정에 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 현장설명회 삭제
|
||||
* DELETE /api/v1/site-briefings/{id}
|
||||
*/
|
||||
export async function deleteSiteBriefing(id: string): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
console.log('Delete site briefing:', id);
|
||||
await apiClient.delete(`/site-briefings/${id}`);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('deleteSiteBriefing error:', error);
|
||||
console.error('현장설명회 삭제 오류:', error);
|
||||
return { success: false, error: '현장설명회 삭제에 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// 현장설명회 일괄 삭제
|
||||
/**
|
||||
* 현장설명회 일괄 삭제
|
||||
* DELETE /api/v1/site-briefings/bulk
|
||||
*/
|
||||
export async function deleteSiteBriefings(ids: string[]): Promise<{ success: boolean; deletedCount?: number; error?: string }> {
|
||||
try {
|
||||
console.log('Delete site briefings:', ids);
|
||||
await apiClient.delete('/site-briefings/bulk', {
|
||||
data: { ids: ids.map((id) => Number(id)) },
|
||||
});
|
||||
return { success: true, deletedCount: ids.length };
|
||||
} catch (error) {
|
||||
console.error('deleteSiteBriefings error:', error);
|
||||
console.error('현장설명회 일괄 삭제 오류:', error);
|
||||
return { success: false, error: '일괄 삭제에 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ export type SiteBriefingStatus = 'scheduled' | 'ongoing' | 'completed' | 'cancel
|
||||
// 입찰 상태
|
||||
export type BidStatus = 'pending' | 'bidding' | 'closed' | 'failed' | 'awarded';
|
||||
|
||||
// 참석자 타입
|
||||
// 참석자 타입 (외부 참석자 - 상세 정보)
|
||||
export interface Attendee {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -18,6 +18,12 @@ export interface Attendee {
|
||||
isAttended: boolean;
|
||||
}
|
||||
|
||||
// 참석자 항목 타입 (내부 직원 또는 직접 입력)
|
||||
export interface AttendeeItem {
|
||||
id: string; // 직원 ID (직접 입력 시 빈 문자열)
|
||||
name: string; // 이름
|
||||
}
|
||||
|
||||
// 현장설명회 타입
|
||||
export interface SiteBriefing {
|
||||
id: string;
|
||||
@@ -41,8 +47,10 @@ export interface SiteBriefing {
|
||||
bidDate: string | null; // 입찰 날짜
|
||||
|
||||
// 참석자 정보
|
||||
attendee: string; // 참석자
|
||||
attendees: Attendee[];
|
||||
attendeeCount: number; // 참석자 수
|
||||
attendanceStatus: AttendanceStatus; // 참석 상태
|
||||
|
||||
// 메타 정보
|
||||
createdAt: string;
|
||||
@@ -173,7 +181,7 @@ export interface SiteBriefingFormData {
|
||||
briefingTime: string; // 현장설명회 시간
|
||||
briefingType: BriefingType; // 구분 (온라인/오프라인)
|
||||
location: string; // 현장설명회 장소
|
||||
attendee: string; // 참석자
|
||||
attendeeItems: AttendeeItem[]; // 참석자 목록 (JSON으로 저장)
|
||||
attendanceStatus: AttendanceStatus; // 상태
|
||||
|
||||
// 입찰 정보
|
||||
@@ -219,7 +227,7 @@ export function getEmptySiteBriefingFormData(): SiteBriefingFormData {
|
||||
briefingTime: '',
|
||||
briefingType: 'offline',
|
||||
location: '',
|
||||
attendee: '',
|
||||
attendeeItems: [],
|
||||
attendanceStatus: 'scheduled',
|
||||
projectName: '',
|
||||
bidDate: '',
|
||||
@@ -233,6 +241,26 @@ export function getEmptySiteBriefingFormData(): SiteBriefingFormData {
|
||||
};
|
||||
}
|
||||
|
||||
// attendee JSON 문자열을 AttendeeItem[]로 파싱
|
||||
export function parseAttendeeItems(attendeeJson: string | null): AttendeeItem[] {
|
||||
if (!attendeeJson) return [];
|
||||
try {
|
||||
const parsed = JSON.parse(attendeeJson);
|
||||
if (Array.isArray(parsed)) {
|
||||
return parsed.filter((item): item is AttendeeItem =>
|
||||
typeof item === 'object' && item !== null && typeof item.name === 'string'
|
||||
);
|
||||
}
|
||||
return [];
|
||||
} catch {
|
||||
// JSON 파싱 실패 시 기존 단일 문자열을 AttendeeItem으로 변환
|
||||
if (attendeeJson.trim()) {
|
||||
return [{ id: '', name: attendeeJson.trim() }];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// SiteBriefing을 FormData로 변환
|
||||
export function siteBriefingToFormData(briefing: SiteBriefing): SiteBriefingFormData {
|
||||
return {
|
||||
@@ -243,8 +271,8 @@ export function siteBriefingToFormData(briefing: SiteBriefing): SiteBriefingForm
|
||||
briefingTime: briefing.briefingTime,
|
||||
briefingType: 'offline', // 기본값
|
||||
location: briefing.location,
|
||||
attendee: '', // 기본값
|
||||
attendanceStatus: 'scheduled', // 기본값
|
||||
attendeeItems: parseAttendeeItems(briefing.attendee),
|
||||
attendanceStatus: briefing.attendanceStatus || 'scheduled',
|
||||
projectName: briefing.title,
|
||||
bidDate: briefing.bidDate || '',
|
||||
siteCount: 0, // 기본값
|
||||
|
||||
@@ -1,195 +1,249 @@
|
||||
'use server';
|
||||
|
||||
import type { Site, SiteStats } from './types';
|
||||
import type { Site, SiteStats, SiteStatus } from './types';
|
||||
import { apiClient } from '@/lib/api';
|
||||
|
||||
// 목업 현장 데이터
|
||||
const MOCK_SITES: Site[] = [
|
||||
{
|
||||
id: '1',
|
||||
siteCode: '123123',
|
||||
partnerId: '1',
|
||||
partnerName: '회사명',
|
||||
siteName: '현장명',
|
||||
address: '-',
|
||||
status: 'unregistered',
|
||||
createdAt: '2025-09-01T00:00:00Z',
|
||||
updatedAt: '2025-09-01T00:00:00Z',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
siteCode: '123123',
|
||||
partnerId: '1',
|
||||
partnerName: '회사명',
|
||||
siteName: '현장명',
|
||||
address: '서울시 강남구 대현빌라 123길',
|
||||
status: 'suspended',
|
||||
createdAt: '2025-09-02T00:00:00Z',
|
||||
updatedAt: '2025-09-02T00:00:00Z',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
siteCode: '123123',
|
||||
partnerId: '2',
|
||||
partnerName: '회사명',
|
||||
siteName: '현장명',
|
||||
address: '서울시 강남구 대현빌라 123길',
|
||||
status: 'active',
|
||||
createdAt: '2025-09-03T00:00:00Z',
|
||||
updatedAt: '2025-09-03T00:00:00Z',
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
siteCode: '123123',
|
||||
partnerId: '1',
|
||||
partnerName: '회사명',
|
||||
siteName: '현장명',
|
||||
address: '서울시 강남구 대현빌라 123길',
|
||||
status: 'active',
|
||||
createdAt: '2025-09-04T00:00:00Z',
|
||||
updatedAt: '2025-09-04T00:00:00Z',
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
siteCode: '123123',
|
||||
partnerId: '3',
|
||||
partnerName: '회사명',
|
||||
siteName: '현장명',
|
||||
address: '서울시 강남구 대현빌라 123길',
|
||||
status: 'active',
|
||||
createdAt: '2025-09-05T00:00:00Z',
|
||||
updatedAt: '2025-09-05T00:00:00Z',
|
||||
},
|
||||
{
|
||||
id: '6',
|
||||
siteCode: '123123',
|
||||
partnerId: '1',
|
||||
partnerName: '회사명',
|
||||
siteName: '현장명',
|
||||
address: '서울시 강남구 대현빌라 123길',
|
||||
status: 'active',
|
||||
createdAt: '2025-09-06T00:00:00Z',
|
||||
updatedAt: '2025-09-06T00:00:00Z',
|
||||
},
|
||||
{
|
||||
id: '7',
|
||||
siteCode: '123123',
|
||||
partnerId: '2',
|
||||
partnerName: '회사명',
|
||||
siteName: '현장명',
|
||||
address: '서울시 강남구 대현빌라 123길',
|
||||
status: 'pending',
|
||||
createdAt: '2025-09-07T00:00:00Z',
|
||||
updatedAt: '2025-09-07T00:00:00Z',
|
||||
},
|
||||
];
|
||||
/**
|
||||
* 주일 기업 - 현장관리 Server Actions
|
||||
* 표준화된 apiClient 사용 버전
|
||||
*/
|
||||
|
||||
// ========================================
|
||||
// API 응답 타입
|
||||
// ========================================
|
||||
|
||||
interface ApiSite {
|
||||
id: number;
|
||||
site_code: string | null;
|
||||
client_id: number | null;
|
||||
name: string;
|
||||
address: string | null;
|
||||
status: SiteStatus;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
client?: {
|
||||
id: number;
|
||||
name: string;
|
||||
} | null;
|
||||
}
|
||||
|
||||
interface ApiSiteStats {
|
||||
total: number;
|
||||
construction: number;
|
||||
unregistered: number;
|
||||
suspended: number;
|
||||
pending: number;
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 타입 변환 함수
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* API 응답 → Site 타입 변환
|
||||
*/
|
||||
function transformSite(apiData: ApiSite): Site {
|
||||
return {
|
||||
id: String(apiData.id),
|
||||
siteCode: apiData.site_code || '',
|
||||
partnerId: apiData.client_id ? String(apiData.client_id) : '',
|
||||
partnerName: apiData.client?.name || '',
|
||||
siteName: apiData.name || '',
|
||||
address: apiData.address || '',
|
||||
status: apiData.status || 'unregistered',
|
||||
createdAt: apiData.created_at || '',
|
||||
updatedAt: apiData.updated_at || '',
|
||||
};
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// API 함수
|
||||
// ========================================
|
||||
|
||||
interface GetSiteListParams {
|
||||
size?: number;
|
||||
page?: number;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
search?: string;
|
||||
status?: string;
|
||||
clientId?: string;
|
||||
sortBy?: string;
|
||||
}
|
||||
|
||||
interface GetSiteListResult {
|
||||
/**
|
||||
* 현장 목록 조회
|
||||
* GET /api/v1/sites
|
||||
*/
|
||||
export async function getSiteList(params: GetSiteListParams = {}): Promise<{
|
||||
success: boolean;
|
||||
data?: {
|
||||
items: Site[];
|
||||
totalCount: number;
|
||||
total: number;
|
||||
page: number;
|
||||
size: number;
|
||||
totalPages: number;
|
||||
};
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// 현장 목록 조회
|
||||
export async function getSiteList(params: GetSiteListParams = {}): Promise<GetSiteListResult> {
|
||||
}> {
|
||||
try {
|
||||
// TODO: API 연동 시 실제 API 호출로 변경
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
const queryParams: Record<string, string> = {};
|
||||
|
||||
let filteredSites = [...MOCK_SITES];
|
||||
// 검색
|
||||
if (params.search) queryParams.search = params.search;
|
||||
|
||||
// 날짜 필터
|
||||
if (params.startDate) {
|
||||
filteredSites = filteredSites.filter(
|
||||
(site) => new Date(site.createdAt) >= new Date(params.startDate!)
|
||||
);
|
||||
}
|
||||
if (params.endDate) {
|
||||
filteredSites = filteredSites.filter(
|
||||
(site) => new Date(site.createdAt) <= new Date(params.endDate!)
|
||||
);
|
||||
// 필터
|
||||
if (params.status && params.status !== 'all') queryParams.status = params.status;
|
||||
if (params.clientId && params.clientId !== 'all') queryParams.client_id = params.clientId;
|
||||
|
||||
// 날짜 범위
|
||||
if (params.startDate) queryParams.start_date = params.startDate;
|
||||
if (params.endDate) queryParams.end_date = params.endDate;
|
||||
|
||||
// 페이지네이션
|
||||
if (params.page) queryParams.page = String(params.page);
|
||||
if (params.size) queryParams.per_page = String(params.size);
|
||||
|
||||
// 정렬
|
||||
if (params.sortBy) {
|
||||
const sortMap: Record<string, { field: string; dir: string }> = {
|
||||
latest: { field: 'created_at', dir: 'desc' },
|
||||
oldest: { field: 'created_at', dir: 'asc' },
|
||||
partnerNameAsc: { field: 'client_id', dir: 'asc' },
|
||||
partnerNameDesc: { field: 'client_id', dir: 'desc' },
|
||||
siteNameAsc: { field: 'name', dir: 'asc' },
|
||||
siteNameDesc: { field: 'name', dir: 'desc' },
|
||||
};
|
||||
const sort = sortMap[params.sortBy];
|
||||
if (sort) {
|
||||
queryParams.sort_by = sort.field;
|
||||
queryParams.sort_dir = sort.dir;
|
||||
}
|
||||
}
|
||||
|
||||
const response = await apiClient.get<{
|
||||
data: ApiSite[];
|
||||
current_page: number;
|
||||
per_page: number;
|
||||
total: number;
|
||||
last_page: number;
|
||||
}>('/sites', { params: queryParams });
|
||||
|
||||
const items = (response.data || []).map(transformSite);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
items: filteredSites,
|
||||
totalCount: filteredSites.length,
|
||||
items,
|
||||
total: response.total || 0,
|
||||
page: response.current_page || 1,
|
||||
size: response.per_page || 20,
|
||||
totalPages: response.last_page || 1,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('getSiteList error:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: '현장 목록을 불러오는데 실패했습니다.',
|
||||
};
|
||||
console.error('현장 목록 조회 오류:', error);
|
||||
return { success: false, error: '현장 목록을 불러오는데 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// 현장 통계 조회
|
||||
export async function getSiteStats(): Promise<{ success: boolean; data?: SiteStats; error?: string }> {
|
||||
/**
|
||||
* 현장 통계 조회
|
||||
* GET /api/v1/sites/stats
|
||||
*/
|
||||
export async function getSiteStats(): Promise<{
|
||||
success: boolean;
|
||||
data?: SiteStats;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
// TODO: API 연동 시 실제 API 호출로 변경
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
|
||||
const total = MOCK_SITES.length;
|
||||
const construction = MOCK_SITES.filter((s) => s.status === 'active').length;
|
||||
const unregistered = MOCK_SITES.filter((s) => s.status === 'unregistered').length;
|
||||
const response = await apiClient.get<ApiSiteStats>('/sites/stats');
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
total,
|
||||
construction,
|
||||
unregistered,
|
||||
total: response.total || 0,
|
||||
construction: response.construction || 0,
|
||||
unregistered: response.unregistered || 0,
|
||||
suspended: response.suspended || 0,
|
||||
pending: response.pending || 0,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('getSiteStats error:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: '현장 통계를 불러오는데 실패했습니다.',
|
||||
};
|
||||
console.error('현장 통계 조회 오류:', error);
|
||||
return { success: false, error: '현장 통계를 불러오는데 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// 현장 삭제
|
||||
export async function deleteSite(id: string): Promise<{ success: boolean; error?: string }> {
|
||||
/**
|
||||
* 현장 삭제
|
||||
* DELETE /api/v1/sites/{id}
|
||||
*/
|
||||
export async function deleteSite(id: string): Promise<{
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
// TODO: API 연동 시 실제 API 호출로 변경
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
await apiClient.delete(`/sites/${id}`);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('deleteSite error:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: '현장 삭제에 실패했습니다.',
|
||||
};
|
||||
console.error('현장 삭제 오류:', error);
|
||||
return { success: false, error: '현장 삭제에 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// 현장 일괄 삭제
|
||||
export async function deleteSites(ids: string[]): Promise<{ success: boolean; deletedCount?: number; error?: string }> {
|
||||
/**
|
||||
* 현장 일괄 삭제
|
||||
* DELETE /api/v1/sites/bulk
|
||||
*/
|
||||
export async function deleteSites(ids: string[]): Promise<{
|
||||
success: boolean;
|
||||
deletedCount?: number;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
// TODO: API 연동 시 실제 API 호출로 변경
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
return {
|
||||
success: true,
|
||||
deletedCount: ids.length,
|
||||
};
|
||||
await apiClient.delete('/sites/bulk', {
|
||||
data: { ids: ids.map((id) => Number(id)) },
|
||||
});
|
||||
return { success: true, deletedCount: ids.length };
|
||||
} catch (error) {
|
||||
console.error('deleteSites error:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: '현장 일괄 삭제에 실패했습니다.',
|
||||
};
|
||||
console.error('현장 일괄 삭제 오류:', error);
|
||||
return { success: false, error: '현장 일괄 삭제에 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 현장 생성/수정 타입
|
||||
// ========================================
|
||||
|
||||
export interface CreateSiteData {
|
||||
siteName: string;
|
||||
partnerId: string;
|
||||
address?: string;
|
||||
status?: SiteStatus;
|
||||
}
|
||||
|
||||
/**
|
||||
* 현장 등록
|
||||
* POST /api/v1/sites
|
||||
*/
|
||||
export async function createSite(data: CreateSiteData): Promise<{
|
||||
success: boolean;
|
||||
data?: Site;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
const apiData = {
|
||||
name: data.siteName,
|
||||
client_id: data.partnerId ? Number(data.partnerId) : null,
|
||||
address: data.address || null,
|
||||
status: data.status || 'unregistered',
|
||||
};
|
||||
|
||||
const response = await apiClient.post<{ success: boolean; data: ApiSite }>('/sites', apiData);
|
||||
return { success: true, data: transformSite(response.data) };
|
||||
} catch (error) {
|
||||
console.error('현장 등록 오류:', error);
|
||||
return { success: false, error: '현장 등록에 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
@@ -17,8 +17,10 @@ export type SiteStatus = 'unregistered' | 'suspended' | 'active' | 'pending';
|
||||
// 현장 통계
|
||||
export interface SiteStats {
|
||||
total: number; // 전체 현장
|
||||
construction: number; // 시공 현장
|
||||
construction: number; // 시공 현장 (active)
|
||||
unregistered: number; // 미등록 현장
|
||||
suspended: number; // 중지 현장
|
||||
pending: number; // 보류 현장
|
||||
}
|
||||
|
||||
// 상태 옵션
|
||||
|
||||
@@ -1,184 +1,256 @@
|
||||
'use server';
|
||||
|
||||
import type { StructureReview, StructureReviewStats } from './types';
|
||||
import type { StructureReview, StructureReviewStats, StructureReviewStatus } from './types';
|
||||
import { apiClient } from '@/lib/api';
|
||||
|
||||
// 목업 데이터
|
||||
const MOCK_STRUCTURE_REVIEWS: StructureReview[] = [
|
||||
{
|
||||
id: '1',
|
||||
reviewNumber: '123123',
|
||||
partnerId: '1',
|
||||
partnerName: '회사명',
|
||||
siteId: '1',
|
||||
siteName: '현장명',
|
||||
requestDate: '2025-12-12',
|
||||
reviewCompany: '회사명',
|
||||
reviewerName: '홍길동',
|
||||
reviewDate: '2025-12-15',
|
||||
completionDate: '2025-12-15',
|
||||
status: 'pending',
|
||||
createdAt: '2025-12-01T00:00:00Z',
|
||||
updatedAt: '2025-12-01T00:00:00Z',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
reviewNumber: '123123',
|
||||
partnerId: '1',
|
||||
partnerName: '회사명',
|
||||
siteId: '2',
|
||||
siteName: '현장명',
|
||||
requestDate: '2025-12-12',
|
||||
reviewCompany: '회사명',
|
||||
reviewerName: '홍길동',
|
||||
reviewDate: '2025-12-15',
|
||||
completionDate: null,
|
||||
status: 'pending',
|
||||
createdAt: '2025-12-02T00:00:00Z',
|
||||
updatedAt: '2025-12-02T00:00:00Z',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
reviewNumber: '123123',
|
||||
partnerId: '2',
|
||||
partnerName: '회사명',
|
||||
siteId: '3',
|
||||
siteName: '현장명',
|
||||
requestDate: '2025-12-12',
|
||||
reviewCompany: '회사명',
|
||||
reviewerName: '홍길동',
|
||||
reviewDate: null,
|
||||
completionDate: null,
|
||||
status: 'pending',
|
||||
createdAt: '2025-12-03T00:00:00Z',
|
||||
updatedAt: '2025-12-03T00:00:00Z',
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
reviewNumber: '123123',
|
||||
partnerId: '2',
|
||||
partnerName: '회사명',
|
||||
siteId: '4',
|
||||
siteName: '현장명',
|
||||
requestDate: '2025-12-12',
|
||||
reviewCompany: '회사명',
|
||||
reviewerName: '홍길동',
|
||||
reviewDate: '2025-12-15',
|
||||
completionDate: '2025-12-15',
|
||||
status: 'completed',
|
||||
createdAt: '2025-12-04T00:00:00Z',
|
||||
updatedAt: '2025-12-04T00:00:00Z',
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
reviewNumber: '123123',
|
||||
partnerId: '3',
|
||||
partnerName: '회사명',
|
||||
siteId: '5',
|
||||
siteName: '현장명',
|
||||
requestDate: '2025-12-12',
|
||||
reviewCompany: '회사명',
|
||||
reviewerName: '홍길동',
|
||||
reviewDate: '2025-12-15',
|
||||
completionDate: '2025-12-15',
|
||||
status: 'completed',
|
||||
createdAt: '2025-12-05T00:00:00Z',
|
||||
updatedAt: '2025-12-05T00:00:00Z',
|
||||
},
|
||||
];
|
||||
/**
|
||||
* 구조검토관리 Server Actions
|
||||
* 표준화된 apiClient 사용 버전
|
||||
*/
|
||||
|
||||
// 구조검토 목록 조회
|
||||
export async function getStructureReviewList(params?: {
|
||||
size?: number;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
}): Promise<{
|
||||
success: boolean;
|
||||
data?: { items: StructureReview[]; total: number };
|
||||
error?: string;
|
||||
}> {
|
||||
// TODO: API 연동
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
// ========================================
|
||||
// API 응답 타입
|
||||
// ========================================
|
||||
|
||||
interface ApiStructureReview {
|
||||
id: number;
|
||||
review_number: string | null;
|
||||
partner_id: number | null;
|
||||
partner_name: string | null;
|
||||
site_id: number | null;
|
||||
site_name: string | null;
|
||||
request_date: string | null;
|
||||
review_company: string | null;
|
||||
reviewer_name: string | null;
|
||||
review_date: string | null;
|
||||
completion_date: string | null;
|
||||
status: StructureReviewStatus;
|
||||
file_url: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
interface ApiStructureReviewStats {
|
||||
total: number;
|
||||
pending: number;
|
||||
completed: number;
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 타입 변환 함수
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* API 응답 → StructureReview 타입 변환
|
||||
*/
|
||||
function transformStructureReview(apiData: ApiStructureReview): StructureReview {
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
items: MOCK_STRUCTURE_REVIEWS,
|
||||
total: MOCK_STRUCTURE_REVIEWS.length,
|
||||
},
|
||||
id: String(apiData.id),
|
||||
reviewNumber: apiData.review_number || '',
|
||||
partnerId: apiData.partner_id ? String(apiData.partner_id) : '',
|
||||
partnerName: apiData.partner_name || '',
|
||||
siteId: apiData.site_id ? String(apiData.site_id) : '',
|
||||
siteName: apiData.site_name || '',
|
||||
requestDate: apiData.request_date || '',
|
||||
reviewCompany: apiData.review_company || '',
|
||||
reviewerName: apiData.reviewer_name || '',
|
||||
reviewDate: apiData.review_date || null,
|
||||
completionDate: apiData.completion_date || null,
|
||||
status: apiData.status || 'pending',
|
||||
fileUrl: apiData.file_url || undefined,
|
||||
createdAt: apiData.created_at || '',
|
||||
updatedAt: apiData.updated_at || '',
|
||||
};
|
||||
}
|
||||
|
||||
// 구조검토 통계 조회
|
||||
/**
|
||||
* StructureReview → API 요청 데이터 변환
|
||||
*/
|
||||
function transformToApiData(data: Partial<StructureReview>): Record<string, unknown> {
|
||||
return {
|
||||
review_number: data.reviewNumber || null,
|
||||
partner_id: data.partnerId ? Number(data.partnerId) : null,
|
||||
partner_name: data.partnerName || null,
|
||||
site_id: data.siteId ? Number(data.siteId) : null,
|
||||
site_name: data.siteName || null,
|
||||
request_date: data.requestDate || null,
|
||||
review_company: data.reviewCompany || null,
|
||||
reviewer_name: data.reviewerName || null,
|
||||
review_date: data.reviewDate || null,
|
||||
completion_date: data.completionDate || null,
|
||||
status: data.status || 'pending',
|
||||
file_url: data.fileUrl || null,
|
||||
};
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// API 함수
|
||||
// ========================================
|
||||
|
||||
interface GetStructureReviewListParams {
|
||||
size?: number;
|
||||
page?: number;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
search?: string;
|
||||
status?: string;
|
||||
partnerId?: string;
|
||||
siteId?: string;
|
||||
sortBy?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 구조검토 목록 조회
|
||||
* GET /api/v1/construction/structure-reviews
|
||||
*/
|
||||
export async function getStructureReviewList(params: GetStructureReviewListParams = {}): Promise<{
|
||||
success: boolean;
|
||||
data?: {
|
||||
items: StructureReview[];
|
||||
total: number;
|
||||
page: number;
|
||||
size: number;
|
||||
totalPages: number;
|
||||
};
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
const queryParams: Record<string, string> = {};
|
||||
|
||||
// 검색
|
||||
if (params.search) queryParams.search = params.search;
|
||||
|
||||
// 필터
|
||||
if (params.status && params.status !== 'all') queryParams.status = params.status;
|
||||
if (params.partnerId && params.partnerId !== 'all') queryParams.partner_id = params.partnerId;
|
||||
if (params.siteId && params.siteId !== 'all') queryParams.site_id = params.siteId;
|
||||
|
||||
// 날짜 범위
|
||||
if (params.startDate) queryParams.start_date = params.startDate;
|
||||
if (params.endDate) queryParams.end_date = params.endDate;
|
||||
|
||||
// 페이지네이션
|
||||
if (params.page) queryParams.page = String(params.page);
|
||||
if (params.size) queryParams.per_page = String(params.size);
|
||||
|
||||
// 정렬
|
||||
if (params.sortBy) {
|
||||
const sortMap: Record<string, { field: string; dir: string }> = {
|
||||
latest: { field: 'created_at', dir: 'desc' },
|
||||
oldest: { field: 'created_at', dir: 'asc' },
|
||||
partnerNameAsc: { field: 'partner_name', dir: 'asc' },
|
||||
partnerNameDesc: { field: 'partner_name', dir: 'desc' },
|
||||
siteNameAsc: { field: 'site_name', dir: 'asc' },
|
||||
siteNameDesc: { field: 'site_name', dir: 'desc' },
|
||||
};
|
||||
const sort = sortMap[params.sortBy];
|
||||
if (sort) {
|
||||
queryParams.sort_by = sort.field;
|
||||
queryParams.sort_dir = sort.dir;
|
||||
}
|
||||
}
|
||||
|
||||
const response = await apiClient.get<{
|
||||
data: ApiStructureReview[];
|
||||
current_page: number;
|
||||
per_page: number;
|
||||
total: number;
|
||||
last_page: number;
|
||||
}>('/construction/structure-reviews', { params: queryParams });
|
||||
|
||||
const items = (response.data || []).map(transformStructureReview);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
items,
|
||||
total: response.total || 0,
|
||||
page: response.current_page || 1,
|
||||
size: response.per_page || 20,
|
||||
totalPages: response.last_page || 1,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('구조검토 목록 조회 오류:', error);
|
||||
return { success: false, error: '구조검토 목록을 불러오는데 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 구조검토 통계 조회
|
||||
* GET /api/v1/construction/structure-reviews/stats
|
||||
*/
|
||||
export async function getStructureReviewStats(): Promise<{
|
||||
success: boolean;
|
||||
data?: StructureReviewStats;
|
||||
error?: string;
|
||||
}> {
|
||||
// TODO: API 연동
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
try {
|
||||
const response = await apiClient.get<ApiStructureReviewStats>('/construction/structure-reviews/stats');
|
||||
|
||||
const pending = MOCK_STRUCTURE_REVIEWS.filter((r) => r.status === 'pending').length;
|
||||
const completed = MOCK_STRUCTURE_REVIEWS.filter((r) => r.status === 'completed').length;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
total: MOCK_STRUCTURE_REVIEWS.length,
|
||||
pending,
|
||||
completed,
|
||||
},
|
||||
};
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
total: response.total || 0,
|
||||
pending: response.pending || 0,
|
||||
completed: response.completed || 0,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('구조검토 통계 조회 오류:', error);
|
||||
return { success: false, error: '구조검토 통계를 불러오는데 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// 구조검토 상세 조회
|
||||
/**
|
||||
* 구조검토 상세 조회
|
||||
* GET /api/v1/construction/structure-reviews/{id}
|
||||
*/
|
||||
export async function getStructureReview(id: string): Promise<{
|
||||
success: boolean;
|
||||
data?: StructureReview;
|
||||
error?: string;
|
||||
}> {
|
||||
// TODO: API 연동
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
try {
|
||||
const response = await apiClient.get<ApiStructureReview>(`/construction/structure-reviews/${id}`);
|
||||
|
||||
const review = MOCK_STRUCTURE_REVIEWS.find((r) => r.id === id);
|
||||
|
||||
if (!review) {
|
||||
return {
|
||||
success: true,
|
||||
data: transformStructureReview(response),
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('구조검토 상세 조회 오류:', error);
|
||||
return { success: false, error: '구조검토 정보를 찾을 수 없습니다.' };
|
||||
}
|
||||
|
||||
return { success: true, data: review };
|
||||
}
|
||||
|
||||
// 구조검토 생성
|
||||
/**
|
||||
* 구조검토 생성
|
||||
* POST /api/v1/construction/structure-reviews
|
||||
*/
|
||||
export async function createStructureReview(data: Partial<StructureReview>): Promise<{
|
||||
success: boolean;
|
||||
data?: StructureReview;
|
||||
error?: string;
|
||||
}> {
|
||||
// TODO: API 연동
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
try {
|
||||
const apiData = transformToApiData(data);
|
||||
const response = await apiClient.post<ApiStructureReview>('/construction/structure-reviews', apiData);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
id: String(Date.now()),
|
||||
reviewNumber: data.reviewNumber || '',
|
||||
partnerId: data.partnerId || '',
|
||||
partnerName: data.partnerName || '',
|
||||
siteId: data.siteId || '',
|
||||
siteName: data.siteName || '',
|
||||
requestDate: data.requestDate || '',
|
||||
reviewCompany: data.reviewCompany || '',
|
||||
reviewerName: data.reviewerName || '',
|
||||
reviewDate: data.reviewDate || null,
|
||||
completionDate: data.completionDate || null,
|
||||
status: data.status || 'pending',
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
};
|
||||
return {
|
||||
success: true,
|
||||
data: transformStructureReview(response),
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('구조검토 생성 오류:', error);
|
||||
return { success: false, error: '구조검토 등록에 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// 구조검토 수정
|
||||
/**
|
||||
* 구조검토 수정
|
||||
* PUT /api/v1/construction/structure-reviews/{id}
|
||||
*/
|
||||
export async function updateStructureReview(
|
||||
id: string,
|
||||
data: Partial<StructureReview>
|
||||
@@ -187,43 +259,53 @@ export async function updateStructureReview(
|
||||
data?: StructureReview;
|
||||
error?: string;
|
||||
}> {
|
||||
// TODO: API 연동
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
try {
|
||||
const apiData = transformToApiData(data);
|
||||
const response = await apiClient.put<ApiStructureReview>(`/construction/structure-reviews/${id}`, apiData);
|
||||
|
||||
const existing = MOCK_STRUCTURE_REVIEWS.find((r) => r.id === id);
|
||||
if (!existing) {
|
||||
return { success: false, error: '구조검토 정보를 찾을 수 없습니다.' };
|
||||
return {
|
||||
success: true,
|
||||
data: transformStructureReview(response),
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('구조검토 수정 오류:', error);
|
||||
return { success: false, error: '구조검토 수정에 실패했습니다.' };
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
...existing,
|
||||
...data,
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// 구조검토 삭제
|
||||
/**
|
||||
* 구조검토 삭제
|
||||
* DELETE /api/v1/construction/structure-reviews/{id}
|
||||
*/
|
||||
export async function deleteStructureReview(id: string): Promise<{
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}> {
|
||||
// TODO: API 연동
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
|
||||
return { success: true };
|
||||
try {
|
||||
await apiClient.delete(`/construction/structure-reviews/${id}`);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('구조검토 삭제 오류:', error);
|
||||
return { success: false, error: '구조검토 삭제에 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// 구조검토 일괄 삭제
|
||||
/**
|
||||
* 구조검토 일괄 삭제
|
||||
* DELETE /api/v1/construction/structure-reviews/bulk
|
||||
*/
|
||||
export async function deleteStructureReviews(ids: string[]): Promise<{
|
||||
success: boolean;
|
||||
deletedCount?: number;
|
||||
error?: string;
|
||||
}> {
|
||||
// TODO: API 연동
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
|
||||
return { success: true, deletedCount: ids.length };
|
||||
try {
|
||||
await apiClient.delete('/construction/structure-reviews/bulk', {
|
||||
data: { ids: ids.map((id) => Number(id)) },
|
||||
});
|
||||
return { success: true, deletedCount: ids.length };
|
||||
} catch (error) {
|
||||
console.error('구조검토 일괄 삭제 오류:', error);
|
||||
return { success: false, error: '구조검토 일괄 삭제에 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
@@ -311,6 +311,32 @@ export function EmployeeForm({
|
||||
}));
|
||||
};
|
||||
|
||||
// 부서 선택 변경 (id와 name 모두 업데이트)
|
||||
const handleDepartmentSelect = (dpId: string, departmentId: string) => {
|
||||
const dept = departments.find(d => String(d.id) === departmentId);
|
||||
if (dept) {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
departmentPositions: prev.departmentPositions.map(dp =>
|
||||
dp.id === dpId ? { ...dp, departmentId: String(dept.id), departmentName: dept.name } : dp
|
||||
),
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
// 직책 선택 변경 (id와 name 모두 업데이트)
|
||||
const handlePositionSelect = (dpId: string, positionId: string) => {
|
||||
const position = titles.find(t => String(t.id) === positionId);
|
||||
if (position) {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
departmentPositions: prev.departmentPositions.map(dp =>
|
||||
dp.id === dpId ? { ...dp, positionId: String(position.id), positionName: position.name } : dp
|
||||
),
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
// 저장
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
@@ -624,13 +650,20 @@ export function EmployeeForm({
|
||||
{fieldSettings.showRank && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="rank">직급</Label>
|
||||
<Input
|
||||
id="rank"
|
||||
<Select
|
||||
value={formData.rank}
|
||||
onChange={(e) => handleChange('rank', e.target.value)}
|
||||
placeholder="직급 입력"
|
||||
onValueChange={(value) => handleChange('rank', value)}
|
||||
disabled={isViewMode}
|
||||
/>
|
||||
>
|
||||
<SelectTrigger disabled={isViewMode}>
|
||||
<SelectValue placeholder="직급 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ranks.map((rank) => (
|
||||
<SelectItem key={rank.id} value={rank.name}>{rank.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -681,20 +714,38 @@ export function EmployeeForm({
|
||||
<div className="space-y-2">
|
||||
{formData.departmentPositions.map((dp) => (
|
||||
<div key={dp.id} className="flex items-center gap-2">
|
||||
<Input
|
||||
value={dp.departmentName}
|
||||
onChange={(e) => handleDepartmentPositionChange(dp.id, 'departmentName', e.target.value)}
|
||||
placeholder="부서명"
|
||||
className="flex-1"
|
||||
<Select
|
||||
value={dp.departmentId}
|
||||
onValueChange={(value) => handleDepartmentSelect(dp.id, value)}
|
||||
disabled={isViewMode}
|
||||
/>
|
||||
<Input
|
||||
value={dp.positionName}
|
||||
onChange={(e) => handleDepartmentPositionChange(dp.id, 'positionName', e.target.value)}
|
||||
placeholder="직책"
|
||||
className="flex-1"
|
||||
>
|
||||
<SelectTrigger className="flex-1" disabled={isViewMode}>
|
||||
<SelectValue placeholder="부서 선택">
|
||||
{dp.departmentName || '부서 선택'}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{departments.map((dept) => (
|
||||
<SelectItem key={dept.id} value={String(dept.id)}>{dept.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select
|
||||
value={dp.positionId}
|
||||
onValueChange={(value) => handlePositionSelect(dp.id, value)}
|
||||
disabled={isViewMode}
|
||||
/>
|
||||
>
|
||||
<SelectTrigger className="flex-1" disabled={isViewMode}>
|
||||
<SelectValue placeholder="직책 선택">
|
||||
{dp.positionName || '직책 선택'}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{titles.map((title) => (
|
||||
<SelectItem key={title.id} value={String(title.id)}>{title.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{!isViewMode && (
|
||||
<Button
|
||||
type="button"
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useDaumPostcode } from "@/hooks/useDaumPostcode";
|
||||
import { useClientList } from "@/hooks/useClientList";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -49,7 +50,8 @@ import {
|
||||
ResponsiveFormTemplate,
|
||||
FormSection,
|
||||
} from "@/components/templates/ResponsiveFormTemplate";
|
||||
import { QuotationSelectDialog, QuotationForSelect, QuotationItem } from "./QuotationSelectDialog";
|
||||
import { QuotationSelectDialog } from "./QuotationSelectDialog";
|
||||
import { type QuotationForSelect, type QuotationItem } from "./actions";
|
||||
import { ItemAddDialog, OrderItem } from "./ItemAddDialog";
|
||||
import { formatAmount } from "@/utils/formatAmount";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -133,15 +135,6 @@ const SHIPPING_COSTS = [
|
||||
{ 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>;
|
||||
@@ -184,6 +177,14 @@ export function OrderRegistration({
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [fieldErrors, setFieldErrors] = useState<FieldErrors>({});
|
||||
|
||||
// 거래처 목록 조회
|
||||
const { clients, fetchClients, isLoading: isClientsLoading } = useClientList();
|
||||
|
||||
// 컴포넌트 마운트 시 거래처 목록 불러오기
|
||||
useEffect(() => {
|
||||
fetchClients({ onlyActive: true, size: 100 });
|
||||
}, [fetchClients]);
|
||||
|
||||
// Daum 우편번호 서비스
|
||||
const { openPostcode } = useDaumPostcode({
|
||||
onComplete: (result) => {
|
||||
@@ -230,6 +231,7 @@ export function OrderRegistration({
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
selectedQuotation: quotation,
|
||||
clientId: quotation.clientId || "", // 견적의 발주처 ID 설정
|
||||
clientName: quotation.client,
|
||||
siteName: quotation.siteName,
|
||||
manager: quotation.manager || "",
|
||||
@@ -237,6 +239,8 @@ export function OrderRegistration({
|
||||
items,
|
||||
}));
|
||||
|
||||
// 발주처 에러 초기화
|
||||
clearFieldError("clientName");
|
||||
toast.success("견적 정보가 불러와졌습니다.");
|
||||
};
|
||||
|
||||
@@ -412,7 +416,7 @@ export function OrderRegistration({
|
||||
<span>{form.selectedQuotation.siteName}</span>
|
||||
<span className="text-muted-foreground mx-2">/</span>
|
||||
<span className="text-green-600 font-medium">
|
||||
{formatAmount(form.selectedQuotation.amount)}원
|
||||
{formatAmount(form.selectedQuotation.amount)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -448,7 +452,7 @@ export function OrderRegistration({
|
||||
<Select
|
||||
value={form.clientId}
|
||||
onValueChange={(value) => {
|
||||
const client = SAMPLE_CLIENTS.find((c) => c.id === value);
|
||||
const client = clients.find((c) => c.id === value);
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
clientId: value,
|
||||
@@ -456,14 +460,15 @@ export function OrderRegistration({
|
||||
}));
|
||||
clearFieldError("clientName");
|
||||
}}
|
||||
disabled={!!form.selectedQuotation || isClientsLoading}
|
||||
>
|
||||
<SelectTrigger className={cn(fieldErrors.clientName && "border-red-500")}>
|
||||
<SelectValue placeholder="발주처 선택">
|
||||
{form.clientName || "발주처 선택"}
|
||||
<SelectValue placeholder={isClientsLoading ? "불러오는 중..." : "발주처 선택"}>
|
||||
{form.clientName || (isClientsLoading ? "불러오는 중..." : "발주처 선택")}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SAMPLE_CLIENTS.map((client) => (
|
||||
{clients.map((client) => (
|
||||
<SelectItem key={client.id} value={client.id}>
|
||||
{client.name}
|
||||
</SelectItem>
|
||||
@@ -486,6 +491,7 @@ export function OrderRegistration({
|
||||
setForm((prev) => ({ ...prev, siteName: e.target.value }));
|
||||
clearFieldError("siteName");
|
||||
}}
|
||||
disabled={!!form.selectedQuotation}
|
||||
className={cn(fieldErrors.siteName && "border-red-500")}
|
||||
/>
|
||||
{fieldErrors.siteName && (
|
||||
@@ -752,8 +758,6 @@ export function OrderRegistration({
|
||||
<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>
|
||||
@@ -765,7 +769,7 @@ export function OrderRegistration({
|
||||
<TableBody>
|
||||
{form.items.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={11} className="text-center py-8 text-muted-foreground">
|
||||
<TableCell colSpan={9} className="text-center py-8 text-muted-foreground">
|
||||
품목이 없습니다. 견적을 선택하거나 품목을 추가해주세요.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@@ -779,8 +783,6 @@ export function OrderRegistration({
|
||||
</code>
|
||||
</TableCell>
|
||||
<TableCell>{item.itemName}</TableCell>
|
||||
<TableCell>{item.type || "-"}</TableCell>
|
||||
<TableCell>{item.symbol || "-"}</TableCell>
|
||||
<TableCell>{item.spec}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Input
|
||||
@@ -798,10 +800,10 @@ export function OrderRegistration({
|
||||
</TableCell>
|
||||
<TableCell className="text-center">{item.unit}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{formatAmount(item.unitPrice)}원
|
||||
{formatAmount(item.unitPrice)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-medium">
|
||||
{formatAmount(item.amount)}원
|
||||
{formatAmount(item.amount)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button
|
||||
@@ -833,7 +835,7 @@ export function OrderRegistration({
|
||||
<div className="flex items-center gap-4 text-sm">
|
||||
<span className="text-muted-foreground">소계:</span>
|
||||
<span className="w-32 text-right">
|
||||
{formatAmount(form.subtotal)}원
|
||||
{formatAmount(form.subtotal)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-sm">
|
||||
@@ -855,7 +857,7 @@ export function OrderRegistration({
|
||||
<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)}원
|
||||
{formatAmount(form.totalAmount)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -4,9 +4,10 @@
|
||||
* 견적 선택 팝업
|
||||
*
|
||||
* 확정된 견적 목록에서 수주 전환할 견적을 선택하는 다이얼로그
|
||||
* API 연동: getQuotesForSelect (FINALIZED 상태 견적만 조회)
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -15,37 +16,10 @@ import {
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Search, FileText, Check } from "lucide-react";
|
||||
import { Search, FileText, Check, Loader2 } 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;
|
||||
}
|
||||
import { getQuotesForSelect, type QuotationForSelect } from "./actions";
|
||||
|
||||
interface QuotationSelectDialogProps {
|
||||
open: boolean;
|
||||
@@ -54,81 +28,6 @@ interface QuotationSelectDialogProps {
|
||||
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 }> = {
|
||||
@@ -151,25 +50,48 @@ export function QuotationSelectDialog({
|
||||
selectedId,
|
||||
}: QuotationSelectDialogProps) {
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [quotations] = useState<QuotationForSelect[]>(SAMPLE_QUOTATIONS);
|
||||
const [quotations, setQuotations] = useState<QuotationForSelect[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// 검색 필터링
|
||||
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)
|
||||
);
|
||||
});
|
||||
// 견적 목록 조회
|
||||
const fetchQuotations = useCallback(async (query?: string) => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await getQuotesForSelect({ q: query, size: 50 });
|
||||
if (result.success && result.data) {
|
||||
setQuotations(result.data.items);
|
||||
} else {
|
||||
setError(result.error || "견적 목록 조회에 실패했습니다.");
|
||||
setQuotations([]);
|
||||
}
|
||||
} catch {
|
||||
setError("서버 오류가 발생했습니다.");
|
||||
setQuotations([]);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 다이얼로그 열릴 때 검색어 초기화
|
||||
// 다이얼로그 열릴 때 데이터 로드
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setSearchTerm("");
|
||||
fetchQuotations();
|
||||
}
|
||||
}, [open]);
|
||||
}, [open, fetchQuotations]);
|
||||
|
||||
// 검색어 변경 시 디바운스 적용하여 API 호출
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
fetchQuotations(searchTerm || undefined);
|
||||
}, 300);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [searchTerm, open, fetchQuotations]);
|
||||
|
||||
const handleSelect = (quotation: QuotationForSelect) => {
|
||||
onSelect(quotation);
|
||||
@@ -199,60 +121,77 @@ export function QuotationSelectDialog({
|
||||
|
||||
{/* 안내 문구 */}
|
||||
<div className="text-sm text-muted-foreground">
|
||||
전환 가능한 견적 {filteredQuotations.length}건 (최종확정 상태)
|
||||
{isLoading ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
견적 목록을 불러오는 중...
|
||||
</span>
|
||||
) : error ? (
|
||||
<span className="text-red-500">{error}</span>
|
||||
) : (
|
||||
`전환 가능한 견적 ${quotations.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} />
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{quotations.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>
|
||||
{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>
|
||||
{quotations.length === 0 && !error && (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
검색 결과가 없습니다.
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
|
||||
1080
src/components/orders/actions.ts
Normal file
1080
src/components/orders/actions.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,26 @@
|
||||
/**
|
||||
* 수주 관련 컴포넌트
|
||||
* 수주 관련 컴포넌트 및 API 함수
|
||||
*/
|
||||
|
||||
// API Actions
|
||||
export {
|
||||
getOrders,
|
||||
getOrderById,
|
||||
createOrder,
|
||||
updateOrder,
|
||||
deleteOrder,
|
||||
deleteOrders,
|
||||
updateOrderStatus,
|
||||
getOrderStats,
|
||||
type Order,
|
||||
type OrderItem as OrderItemApi,
|
||||
type OrderFormData as OrderApiFormData,
|
||||
type OrderItemFormData,
|
||||
type OrderStats,
|
||||
type OrderStatus,
|
||||
} from "./actions";
|
||||
|
||||
// Components
|
||||
export { OrderRegistration, type OrderFormData } from "./OrderRegistration";
|
||||
export { QuotationSelectDialog, type QuotationForSelect, type QuotationItem } from "./QuotationSelectDialog";
|
||||
export { ItemAddDialog, type OrderItem } from "./ItemAddDialog";
|
||||
|
||||
@@ -178,39 +178,36 @@ export function ProcessDetail({ process }: ProcessDetailProps) {
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<Package className="h-4 w-4" />
|
||||
개별 품목
|
||||
{individualItems.length > 0 && individualItems[0].items && (
|
||||
<Badge variant="secondary" className="ml-2">
|
||||
{individualItems[0].items.length}개
|
||||
</Badge>
|
||||
)}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-6">
|
||||
{individualItems.length === 0 ? (
|
||||
{individualItems.length === 0 || !individualItems[0].items?.length ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
<Package className="h-10 w-10 mx-auto mb-3 opacity-30" />
|
||||
<p className="text-sm">등록된 개별 품목이 없습니다</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{individualItems.map((rule) => (
|
||||
<div
|
||||
key={rule.id}
|
||||
className="flex items-center justify-between p-4 border rounded-lg"
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<Badge variant={rule.isActive ? 'default' : 'secondary'}>
|
||||
{rule.isActive ? '활성' : '비활성'}
|
||||
</Badge>
|
||||
<div>
|
||||
<div className="font-medium">
|
||||
{rule.conditionValue}
|
||||
</div>
|
||||
{rule.description && (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{rule.description}
|
||||
</div>
|
||||
)}
|
||||
<div className="max-h-80 overflow-y-auto">
|
||||
<div className="space-y-2">
|
||||
{individualItems[0].items.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="flex items-center justify-between p-3 border rounded-lg bg-muted/30 hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Badge variant="outline" className="font-mono text-xs">
|
||||
{item.code}
|
||||
</Badge>
|
||||
<span className="font-medium">{item.name}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant="outline">우선순위: {rule.priority}</Badge>
|
||||
</div>
|
||||
))}
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
@@ -74,7 +74,7 @@ export function RuleModal({ open, onOpenChange, onAdd, editRule }: RuleModalProp
|
||||
// 개별 품목용 상태
|
||||
const [searchKeyword, setSearchKeyword] = useState('');
|
||||
const [selectedItemType, setSelectedItemType] = useState('all');
|
||||
const [selectedItemCodes, setSelectedItemCodes] = useState<Set<string>>(new Set());
|
||||
const [selectedItemIds, setSelectedItemIds] = useState<Set<string>>(new Set());
|
||||
|
||||
// 품목 목록 API 상태
|
||||
const [itemList, setItemList] = useState<ItemOption[]>([]);
|
||||
@@ -86,16 +86,35 @@ export function RuleModal({ open, onOpenChange, onAdd, editRule }: RuleModalProp
|
||||
const items = await getItemList({
|
||||
q: q || undefined,
|
||||
itemType: itemType === 'all' ? undefined : itemType,
|
||||
size: 100,
|
||||
size: 1000, // 전체 품목 조회
|
||||
});
|
||||
setItemList(items);
|
||||
setIsItemsLoading(false);
|
||||
}, []);
|
||||
|
||||
// 검색어 유효성 검사 함수
|
||||
const isValidSearchKeyword = (keyword: string): boolean => {
|
||||
if (!keyword || keyword.trim() === '') return false;
|
||||
|
||||
const trimmed = keyword.trim();
|
||||
// 한글이 포함되어 있으면 1자 이상
|
||||
const hasKorean = /[가-힣]/.test(trimmed);
|
||||
if (hasKorean) return trimmed.length >= 1;
|
||||
|
||||
// 영어/숫자만 있으면 2자 이상
|
||||
return trimmed.length >= 2;
|
||||
};
|
||||
|
||||
// 검색어/품목유형 변경 시 API 호출 (debounce)
|
||||
useEffect(() => {
|
||||
if (registrationType !== 'individual') return;
|
||||
|
||||
// 검색어 유효성 검사 - 유효하지 않으면 빈 목록
|
||||
if (!isValidSearchKeyword(searchKeyword)) {
|
||||
setItemList([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
loadItems(searchKeyword, selectedItemType);
|
||||
}, 300);
|
||||
@@ -103,21 +122,30 @@ export function RuleModal({ open, onOpenChange, onAdd, editRule }: RuleModalProp
|
||||
return () => clearTimeout(timer);
|
||||
}, [searchKeyword, selectedItemType, registrationType, loadItems]);
|
||||
|
||||
// 모달 열릴 때 품목 목록 초기 로드
|
||||
// 품목유형 변경 시 검색어가 유효하면 재검색
|
||||
useEffect(() => {
|
||||
if (registrationType !== 'individual') return;
|
||||
if (!isValidSearchKeyword(searchKeyword)) return;
|
||||
|
||||
loadItems(searchKeyword, selectedItemType);
|
||||
}, [selectedItemType]);
|
||||
|
||||
// 모달 열릴 때 품목 목록 초기화 (초기 로드 안함)
|
||||
useEffect(() => {
|
||||
if (open && registrationType === 'individual') {
|
||||
loadItems('', 'all');
|
||||
setItemList([]);
|
||||
setSearchKeyword('');
|
||||
}
|
||||
}, [open, registrationType, loadItems]);
|
||||
}, [open, registrationType]);
|
||||
|
||||
// 체크박스 토글
|
||||
const handleToggleItem = (code: string) => {
|
||||
setSelectedItemCodes((prev) => {
|
||||
const handleToggleItem = (id: string) => {
|
||||
setSelectedItemIds((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(code)) {
|
||||
newSet.delete(code);
|
||||
if (newSet.has(id)) {
|
||||
newSet.delete(id);
|
||||
} else {
|
||||
newSet.add(code);
|
||||
newSet.add(id);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
@@ -125,13 +153,13 @@ export function RuleModal({ open, onOpenChange, onAdd, editRule }: RuleModalProp
|
||||
|
||||
// 전체 선택
|
||||
const handleSelectAll = () => {
|
||||
const allCodes = itemList.map((item) => item.code);
|
||||
setSelectedItemCodes(new Set(allCodes));
|
||||
const allIds = itemList.map((item) => item.id);
|
||||
setSelectedItemIds(new Set(allIds));
|
||||
};
|
||||
|
||||
// 초기화
|
||||
const handleResetSelection = () => {
|
||||
setSelectedItemCodes(new Set());
|
||||
setSelectedItemIds(new Set());
|
||||
};
|
||||
|
||||
// 모달 열릴 때 초기화 또는 수정 데이터 로드
|
||||
@@ -149,12 +177,12 @@ export function RuleModal({ open, onOpenChange, onAdd, editRule }: RuleModalProp
|
||||
setSearchKeyword('');
|
||||
setSelectedItemType('all');
|
||||
|
||||
// 개별 품목인 경우 선택된 품목 코드 설정
|
||||
// 개별 품목인 경우 선택된 품목 ID 설정
|
||||
if (editRule.registrationType === 'individual') {
|
||||
const codes = editRule.conditionValue.split(',').filter(Boolean);
|
||||
setSelectedItemCodes(new Set(codes));
|
||||
const ids = editRule.conditionValue.split(',').filter(Boolean);
|
||||
setSelectedItemIds(new Set(ids));
|
||||
} else {
|
||||
setSelectedItemCodes(new Set());
|
||||
setSelectedItemIds(new Set());
|
||||
}
|
||||
} else {
|
||||
// 추가 모드: 초기화
|
||||
@@ -167,7 +195,7 @@ export function RuleModal({ open, onOpenChange, onAdd, editRule }: RuleModalProp
|
||||
setIsActive(true);
|
||||
setSearchKeyword('');
|
||||
setSelectedItemType('all');
|
||||
setSelectedItemCodes(new Set());
|
||||
setSelectedItemIds(new Set());
|
||||
}
|
||||
}
|
||||
}, [open, editRule]);
|
||||
@@ -179,7 +207,7 @@ export function RuleModal({ open, onOpenChange, onAdd, editRule }: RuleModalProp
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
if (selectedItemCodes.size === 0) {
|
||||
if (selectedItemIds.size === 0) {
|
||||
alert('품목을 최소 1개 이상 선택해주세요.');
|
||||
return;
|
||||
}
|
||||
@@ -188,7 +216,7 @@ export function RuleModal({ open, onOpenChange, onAdd, editRule }: RuleModalProp
|
||||
// 개별 품목의 경우 conditionValue에 품목코드들을 저장
|
||||
const finalConditionValue =
|
||||
registrationType === 'individual'
|
||||
? Array.from(selectedItemCodes).join(',')
|
||||
? Array.from(selectedItemIds).join(',')
|
||||
: conditionValue.trim();
|
||||
|
||||
onAdd({
|
||||
@@ -211,7 +239,7 @@ export function RuleModal({ open, onOpenChange, onAdd, editRule }: RuleModalProp
|
||||
setIsActive(true);
|
||||
setSearchKeyword('');
|
||||
setSelectedItemType('all');
|
||||
setSelectedItemCodes(new Set());
|
||||
setSelectedItemIds(new Set());
|
||||
|
||||
onOpenChange(false);
|
||||
};
|
||||
@@ -389,7 +417,7 @@ export function RuleModal({ open, onOpenChange, onAdd, editRule }: RuleModalProp
|
||||
{isItemsLoading ? (
|
||||
'로딩 중...'
|
||||
) : (
|
||||
<>품목 목록 ({itemList.length}개) | 선택됨 ({selectedItemCodes.size}개)</>
|
||||
<>품목 목록 ({itemList.length}개) | 선택됨 ({selectedItemIds.size}개)</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
@@ -411,7 +439,7 @@ export function RuleModal({ open, onOpenChange, onAdd, editRule }: RuleModalProp
|
||||
size="sm"
|
||||
onClick={handleResetSelection}
|
||||
className="text-xs h-7"
|
||||
disabled={selectedItemCodes.size === 0}
|
||||
disabled={selectedItemIds.size === 0}
|
||||
>
|
||||
초기화
|
||||
</Button>
|
||||
@@ -439,7 +467,9 @@ export function RuleModal({ open, onOpenChange, onAdd, editRule }: RuleModalProp
|
||||
) : itemList.length === 0 ? (
|
||||
<TableRow key="empty">
|
||||
<TableCell colSpan={4} className="text-center text-muted-foreground py-8">
|
||||
검색 결과가 없습니다
|
||||
{searchKeyword.trim() === ''
|
||||
? '품목을 검색해주세요 (한글 1자 이상, 영문 2자 이상)'
|
||||
: '검색 결과가 없습니다'}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
@@ -447,12 +477,12 @@ export function RuleModal({ open, onOpenChange, onAdd, editRule }: RuleModalProp
|
||||
<TableRow
|
||||
key={item.id}
|
||||
className="cursor-pointer hover:bg-muted/50"
|
||||
onClick={() => handleToggleItem(item.code)}
|
||||
onClick={() => handleToggleItem(item.id)}
|
||||
>
|
||||
<TableCell>
|
||||
<Checkbox
|
||||
checked={selectedItemCodes.has(item.code)}
|
||||
onCheckedChange={() => handleToggleItem(item.code)}
|
||||
checked={selectedItemIds.has(item.id)}
|
||||
onCheckedChange={() => handleToggleItem(item.id)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</TableCell>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||
import { serverFetch } from '@/lib/api/fetch-wrapper';
|
||||
import type { Process, ProcessFormData, ClassificationRule } from '@/types/process';
|
||||
import type { Process, ProcessFormData, ClassificationRule, IndividualItem } from '@/types/process';
|
||||
|
||||
// ============================================================================
|
||||
// API 타입 정의
|
||||
@@ -26,6 +26,7 @@ interface ApiProcess {
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
classification_rules?: ApiClassificationRule[];
|
||||
process_items?: ApiProcessItem[];
|
||||
}
|
||||
|
||||
interface ApiClassificationRule {
|
||||
@@ -42,6 +43,19 @@ interface ApiClassificationRule {
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
interface ApiProcessItem {
|
||||
id: number;
|
||||
process_id: number;
|
||||
item_id: number;
|
||||
priority: number;
|
||||
is_active: boolean;
|
||||
item?: {
|
||||
id: number;
|
||||
code: string;
|
||||
name: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface ApiResponse<T> {
|
||||
success: boolean;
|
||||
message: string;
|
||||
@@ -61,6 +75,12 @@ interface PaginatedResponse<T> {
|
||||
// ============================================================================
|
||||
|
||||
function transformApiToFrontend(apiData: ApiProcess): Process {
|
||||
// Pattern 규칙 변환
|
||||
const patternRules = (apiData.classification_rules ?? []).map(transformRuleApiToFrontend);
|
||||
|
||||
// 개별 품목 → individual 분류 규칙으로 변환
|
||||
const individualRules = transformProcessItemsToRules(apiData.process_items ?? []);
|
||||
|
||||
return {
|
||||
id: String(apiData.id),
|
||||
processCode: apiData.process_code,
|
||||
@@ -69,7 +89,7 @@ function transformApiToFrontend(apiData: ApiProcess): Process {
|
||||
processType: apiData.process_type as Process['processType'],
|
||||
department: apiData.department ?? '',
|
||||
workLogTemplate: apiData.work_log_template ?? undefined,
|
||||
classificationRules: (apiData.classification_rules ?? []).map(transformRuleApiToFrontend),
|
||||
classificationRules: [...patternRules, ...individualRules],
|
||||
requiredWorkers: apiData.required_workers,
|
||||
equipmentInfo: apiData.equipment_info ?? undefined,
|
||||
workSteps: apiData.work_steps ?? [],
|
||||
@@ -80,6 +100,44 @@ function transformApiToFrontend(apiData: ApiProcess): Process {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* process_items 배열을 individual 분류 규칙으로 변환
|
||||
* 모든 개별 품목을 하나의 규칙으로 통합
|
||||
*/
|
||||
function transformProcessItemsToRules(processItems: ApiProcessItem[]): ClassificationRule[] {
|
||||
if (processItems.length === 0) return [];
|
||||
|
||||
const activeItems = processItems.filter(pi => pi.is_active);
|
||||
if (activeItems.length === 0) return [];
|
||||
|
||||
// 모든 품목 ID를 쉼표로 구분하여 하나의 규칙으로 통합
|
||||
const itemIds = activeItems
|
||||
.map(pi => String(pi.item_id))
|
||||
.join(',');
|
||||
|
||||
// 품목 상세 정보 추출 (code, name 포함)
|
||||
const items: IndividualItem[] = activeItems
|
||||
.filter(pi => pi.item) // item 정보가 있는 것만
|
||||
.map(pi => ({
|
||||
id: String(pi.item!.id),
|
||||
code: pi.item!.code,
|
||||
name: pi.item!.name,
|
||||
}));
|
||||
|
||||
return [{
|
||||
id: `individual-${Date.now()}`,
|
||||
registrationType: 'individual',
|
||||
ruleType: '품목코드',
|
||||
matchingType: 'equals',
|
||||
conditionValue: itemIds,
|
||||
priority: 0,
|
||||
description: `개별 품목 ${activeItems.length}개`,
|
||||
isActive: true,
|
||||
createdAt: new Date().toISOString(),
|
||||
items, // 품목 상세 정보 추가
|
||||
}];
|
||||
}
|
||||
|
||||
function transformRuleApiToFrontend(apiRule: ApiClassificationRule): ClassificationRule {
|
||||
return {
|
||||
id: String(apiRule.id),
|
||||
@@ -95,6 +153,24 @@ function transformRuleApiToFrontend(apiRule: ApiClassificationRule): Classificat
|
||||
}
|
||||
|
||||
function transformFrontendToApi(data: ProcessFormData): Record<string, unknown> {
|
||||
// 패턴 규칙만 분리 (individual 제외)
|
||||
const patternRules = data.classificationRules.filter(
|
||||
(rule) => rule.registrationType === 'pattern'
|
||||
);
|
||||
|
||||
// 개별 품목 규칙에서 item_ids 추출
|
||||
const individualRules = data.classificationRules.filter(
|
||||
(rule) => rule.registrationType === 'individual'
|
||||
);
|
||||
|
||||
// 개별 품목의 conditionValue에서 ID 배열 추출 (쉼표 구분)
|
||||
const itemIds: number[] = individualRules.flatMap((rule) =>
|
||||
rule.conditionValue
|
||||
.split(',')
|
||||
.map((id) => parseInt(id.trim(), 10))
|
||||
.filter((n) => !isNaN(n) && n > 0)
|
||||
);
|
||||
|
||||
return {
|
||||
process_name: data.processName,
|
||||
process_type: data.processType,
|
||||
@@ -105,8 +181,8 @@ function transformFrontendToApi(data: ProcessFormData): Record<string, unknown>
|
||||
work_steps: data.workSteps ? data.workSteps.split(',').map((s) => s.trim()).filter(Boolean) : [],
|
||||
note: data.note || null,
|
||||
is_active: data.isActive,
|
||||
classification_rules: data.classificationRules.map((rule) => ({
|
||||
registration_type: rule.registrationType,
|
||||
// 패턴 규칙만 전송 (registration_type 제외)
|
||||
classification_rules: patternRules.map((rule) => ({
|
||||
rule_type: rule.ruleType,
|
||||
matching_type: rule.matchingType,
|
||||
condition_value: rule.conditionValue,
|
||||
@@ -114,6 +190,8 @@ function transformFrontendToApi(data: ProcessFormData): Record<string, unknown>
|
||||
description: rule.description || null,
|
||||
is_active: rule.isActive,
|
||||
})),
|
||||
// 개별 품목 ID 배열 전송
|
||||
item_ids: itemIds,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -494,8 +572,8 @@ export async function getDepartmentOptions(): Promise<DepartmentOption[]> {
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
if (result.success && result.data) {
|
||||
return result.data.map((dept: { id: number; name: string }) => ({
|
||||
if (result.success && result.data?.data) {
|
||||
return result.data.data.map((dept: { id: number; name: string }) => ({
|
||||
value: dept.name,
|
||||
label: dept.name,
|
||||
}));
|
||||
@@ -552,12 +630,12 @@ export async function getItemList(params?: GetItemListParams): Promise<ItemOptio
|
||||
|
||||
const result = await response.json();
|
||||
if (result.success && result.data?.data) {
|
||||
return result.data.data.map((item: { id: number; item_name: string; item_code?: string; item_type?: string }) => ({
|
||||
return result.data.data.map((item: { id: number; name: string; code?: string; item_type?: string }) => ({
|
||||
value: String(item.id),
|
||||
label: item.item_name,
|
||||
code: item.item_code || '',
|
||||
label: item.name,
|
||||
code: item.code || '',
|
||||
id: String(item.id),
|
||||
fullName: item.item_name,
|
||||
fullName: item.name,
|
||||
type: item.item_type || '',
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -21,6 +21,18 @@ import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||
import { getSalesOrdersForWorkOrder } from './actions';
|
||||
import type { SalesOrder } from './types';
|
||||
|
||||
// Debounce 훅
|
||||
function useDebounce<T>(value: T, delay: number): T {
|
||||
const [debouncedValue, setDebouncedValue] = useState<T>(value);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setDebouncedValue(value), delay);
|
||||
return () => clearTimeout(timer);
|
||||
}, [value, delay]);
|
||||
|
||||
return debouncedValue;
|
||||
}
|
||||
|
||||
interface SalesOrderSelectModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
@@ -36,12 +48,15 @@ export function SalesOrderSelectModal({
|
||||
const [salesOrders, setSalesOrders] = useState<SalesOrder[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
// 디바운스된 검색어 (300ms 딜레이)
|
||||
const debouncedSearchTerm = useDebounce(searchTerm, 300);
|
||||
|
||||
// API로 수주 목록 로드
|
||||
const loadSalesOrders = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const result = await getSalesOrdersForWorkOrder({
|
||||
q: searchTerm || undefined,
|
||||
q: debouncedSearchTerm || undefined,
|
||||
});
|
||||
if (result.success) {
|
||||
// API 응답을 SalesOrder 타입으로 변환
|
||||
@@ -66,7 +81,7 @@ export function SalesOrderSelectModal({
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [searchTerm]);
|
||||
}, [debouncedSearchTerm]);
|
||||
|
||||
// 모달이 열릴 때 데이터 로드
|
||||
useEffect(() => {
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* API 연동 완료 (2025-12-26)
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { ArrowLeft, FileText, X, Edit2, Loader2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -26,7 +26,7 @@ import { SalesOrderSelectModal } from './SalesOrderSelectModal';
|
||||
import { AssigneeSelectModal } from './AssigneeSelectModal';
|
||||
import { toast } from 'sonner';
|
||||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||
import { createWorkOrder } from './actions';
|
||||
import { createWorkOrder, getProcessOptions, type ProcessOption } from './actions';
|
||||
import { PROCESS_TYPE_LABELS, type ProcessType, type SalesOrder } from './types';
|
||||
|
||||
// Validation 에러 타입
|
||||
@@ -39,6 +39,7 @@ const FIELD_NAME_MAP: Record<string, string> = {
|
||||
selectedOrder: '수주',
|
||||
client: '발주처',
|
||||
projectName: '현장명',
|
||||
processId: '공정',
|
||||
shipmentDate: '출고예정일',
|
||||
};
|
||||
|
||||
@@ -56,7 +57,7 @@ interface FormData {
|
||||
itemCount: number;
|
||||
|
||||
// 작업지시 정보
|
||||
processType: ProcessType;
|
||||
processId: number | null; // 공정 ID (FK → processes.id)
|
||||
shipmentDate: string;
|
||||
priority: number;
|
||||
assignees: string[];
|
||||
@@ -72,7 +73,7 @@ const initialFormData: FormData = {
|
||||
projectName: '',
|
||||
orderNo: '',
|
||||
itemCount: 0,
|
||||
processType: 'screen',
|
||||
processId: null,
|
||||
shipmentDate: '',
|
||||
priority: 5,
|
||||
assignees: [],
|
||||
@@ -88,6 +89,27 @@ export function WorkOrderCreate() {
|
||||
const [assigneeNames, setAssigneeNames] = useState<string[]>([]);
|
||||
const [validationErrors, setValidationErrors] = useState<ValidationErrors>({});
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [processOptions, setProcessOptions] = useState<ProcessOption[]>([]);
|
||||
const [isLoadingProcesses, setIsLoadingProcesses] = useState(true);
|
||||
|
||||
// 공정 옵션 로드
|
||||
useEffect(() => {
|
||||
async function loadProcessOptions() {
|
||||
setIsLoadingProcesses(true);
|
||||
const result = await getProcessOptions();
|
||||
if (result.success) {
|
||||
setProcessOptions(result.data);
|
||||
// 첫 번째 공정을 기본값으로 설정
|
||||
if (result.data.length > 0 && !formData.processId) {
|
||||
setFormData(prev => ({ ...prev, processId: result.data[0].id }));
|
||||
}
|
||||
} else {
|
||||
toast.error(result.error || '공정 목록을 불러오는데 실패했습니다.');
|
||||
}
|
||||
setIsLoadingProcesses(false);
|
||||
}
|
||||
loadProcessOptions();
|
||||
}, []);
|
||||
|
||||
// 수주 선택 핸들러
|
||||
const handleSelectOrder = (order: SalesOrder) => {
|
||||
@@ -105,7 +127,7 @@ export function WorkOrderCreate() {
|
||||
const handleClearOrder = () => {
|
||||
setFormData({
|
||||
...initialFormData,
|
||||
processType: formData.processType,
|
||||
processId: formData.processId,
|
||||
shipmentDate: formData.shipmentDate,
|
||||
priority: formData.priority,
|
||||
});
|
||||
@@ -129,6 +151,10 @@ export function WorkOrderCreate() {
|
||||
}
|
||||
}
|
||||
|
||||
if (!formData.processId) {
|
||||
errors.processId = '공정을 선택해주세요';
|
||||
}
|
||||
|
||||
if (!formData.shipmentDate) {
|
||||
errors.shipmentDate = '출고예정일을 선택해주세요';
|
||||
}
|
||||
@@ -150,9 +176,9 @@ export function WorkOrderCreate() {
|
||||
const result = await createWorkOrder({
|
||||
salesOrderId: formData.selectedOrder?.id ? parseInt(formData.selectedOrder.id) : undefined,
|
||||
projectName: formData.projectName,
|
||||
processType: formData.processType,
|
||||
processId: formData.processId!, // 공정 ID (FK → processes.id)
|
||||
scheduledDate: formData.shipmentDate,
|
||||
assigneeId: formData.assignees.length > 0 ? parseInt(formData.assignees[0]) : undefined,
|
||||
assigneeIds: formData.assignees.map(id => parseInt(id)),
|
||||
memo: formData.note || undefined,
|
||||
});
|
||||
|
||||
@@ -176,14 +202,10 @@ export function WorkOrderCreate() {
|
||||
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];
|
||||
// 선택된 공정의 코드 가져오기
|
||||
const getSelectedProcessCode = (): string => {
|
||||
const selectedProcess = processOptions.find(p => p.id === formData.processId);
|
||||
return selectedProcess?.processCode || '-';
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -398,22 +420,23 @@ export function WorkOrderCreate() {
|
||||
<div className="space-y-2">
|
||||
<Label>공정구분 *</Label>
|
||||
<Select
|
||||
value={formData.processType}
|
||||
onValueChange={(value) => setFormData({ ...formData, processType: value as ProcessType })}
|
||||
value={formData.processId?.toString() || ''}
|
||||
onValueChange={(value) => setFormData({ ...formData, processId: parseInt(value) })}
|
||||
disabled={isLoadingProcesses}
|
||||
>
|
||||
<SelectTrigger className="bg-white">
|
||||
<SelectValue />
|
||||
<SelectValue placeholder={isLoadingProcesses ? '로딩 중...' : '공정을 선택하세요'} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(PROCESS_TYPE_LABELS).map(([key, label]) => (
|
||||
<SelectItem key={key} value={key}>
|
||||
{label}
|
||||
{processOptions.map((process) => (
|
||||
<SelectItem key={process.id} value={process.id.toString()}>
|
||||
{process.processName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
공정코드: {getProcessCode(formData.processType)}
|
||||
공정코드: {getSelectedProcessCode()}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { FileText, List, AlertTriangle, Play, CheckCircle2 } from 'lucide-react';
|
||||
import { FileText, List, AlertTriangle, Play, CheckCircle2, Loader2 } from 'lucide-react';
|
||||
import { ContentLoadingSpinner } from '@/components/ui/loading-spinner';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@@ -23,9 +23,8 @@ import { PageLayout } from '@/components/organisms/PageLayout';
|
||||
import { WorkLogModal } from '../WorkerScreen/WorkLogModal';
|
||||
import { toast } from 'sonner';
|
||||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||
import { getWorkOrderById } from './actions';
|
||||
import { getWorkOrderById, updateWorkOrderStatus, updateWorkOrderItemStatus, type WorkOrderItemStatus } from './actions';
|
||||
import {
|
||||
PROCESS_TYPE_LABELS,
|
||||
WORK_ORDER_STATUS_LABELS,
|
||||
WORK_ORDER_STATUS_COLORS,
|
||||
ITEM_STATUS_LABELS,
|
||||
@@ -35,33 +34,47 @@ import {
|
||||
BENDING_PROCESS_STEPS,
|
||||
type WorkOrder,
|
||||
type ProcessType,
|
||||
type ProcessStep,
|
||||
} from './types';
|
||||
|
||||
// 공정 진행 단계 컴포넌트
|
||||
function ProcessSteps({
|
||||
processType,
|
||||
currentStep,
|
||||
workSteps,
|
||||
}: {
|
||||
processType: ProcessType;
|
||||
currentStep: number;
|
||||
workSteps?: ProcessStep[];
|
||||
}) {
|
||||
const steps =
|
||||
processType === 'screen'
|
||||
// 동적 workSteps 우선 사용, 없으면 하드코딩 폴백
|
||||
const steps = workSteps && workSteps.length > 0
|
||||
? workSteps
|
||||
: processType === 'screen'
|
||||
? SCREEN_PROCESS_STEPS
|
||||
: processType === 'slat'
|
||||
? SLAT_PROCESS_STEPS
|
||||
: BENDING_PROCESS_STEPS;
|
||||
? SLAT_PROCESS_STEPS
|
||||
: BENDING_PROCESS_STEPS;
|
||||
|
||||
if (steps.length === 0) {
|
||||
return (
|
||||
<div className="bg-white border rounded-lg p-6">
|
||||
<h3 className="font-semibold mb-4">공정 진행</h3>
|
||||
<p className="text-gray-500">공정 단계가 설정되지 않았습니다.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{steps.map((step, index) => {
|
||||
const isCompleted = index < currentStep;
|
||||
const isCurrent = index === currentStep;
|
||||
|
||||
return (
|
||||
<div key={step.key} className="flex items-center">
|
||||
<div key={step.key || `step-${index}`} className="flex items-center">
|
||||
<div
|
||||
className={`flex items-center gap-2 px-4 py-2 rounded-full border ${
|
||||
isCompleted
|
||||
@@ -193,6 +206,8 @@ export function WorkOrderDetail({ orderId }: WorkOrderDetailProps) {
|
||||
const [isWorkLogOpen, setIsWorkLogOpen] = useState(false);
|
||||
const [order, setOrder] = useState<WorkOrder | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isStatusUpdating, setIsStatusUpdating] = useState(false);
|
||||
const [updatingItemId, setUpdatingItemId] = useState<number | null>(null);
|
||||
|
||||
// API에서 데이터 로드
|
||||
const loadData = useCallback(async () => {
|
||||
@@ -205,7 +220,6 @@ export function WorkOrderDetail({ orderId }: WorkOrderDetailProps) {
|
||||
toast.error(result.error || '작업지시 조회에 실패했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('[WorkOrderDetail] loadData error:', error);
|
||||
toast.error('데이터 로드 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
@@ -217,12 +231,83 @@ export function WorkOrderDetail({ orderId }: WorkOrderDetailProps) {
|
||||
loadData();
|
||||
}, [loadData]);
|
||||
|
||||
// 상태 변경 핸들러
|
||||
const handleStatusChange = useCallback(async (newStatus: 'waiting' | 'in_progress' | 'completed') => {
|
||||
if (!order) return;
|
||||
|
||||
setIsStatusUpdating(true);
|
||||
try {
|
||||
const result = await updateWorkOrderStatus(orderId, newStatus);
|
||||
if (result.success && result.data) {
|
||||
setOrder(result.data);
|
||||
const statusLabels = {
|
||||
waiting: '작업대기',
|
||||
in_progress: '작업중',
|
||||
completed: '작업완료',
|
||||
};
|
||||
toast.success(`상태가 '${statusLabels[newStatus]}'(으)로 변경되었습니다.`);
|
||||
} else {
|
||||
toast.error(result.error || '상태 변경에 실패했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[WorkOrderDetail] handleStatusChange error:', error);
|
||||
toast.error('상태 변경 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
setIsStatusUpdating(false);
|
||||
}
|
||||
}, [order, orderId]);
|
||||
|
||||
// 품목 상태 변경 핸들러
|
||||
const handleItemStatusChange = useCallback(async (itemId: number, newStatus: WorkOrderItemStatus) => {
|
||||
if (!order) return;
|
||||
|
||||
setUpdatingItemId(itemId);
|
||||
try {
|
||||
const result = await updateWorkOrderItemStatus(orderId, itemId, newStatus);
|
||||
if (result.success) {
|
||||
// 로컬 상태 업데이트 (품목 + 작업지시 상태)
|
||||
setOrder(prev => {
|
||||
if (!prev) return prev;
|
||||
return {
|
||||
...prev,
|
||||
status: result.workOrderStatus || prev.status,
|
||||
items: prev.items.map(item =>
|
||||
item.id === itemId ? { ...item, status: newStatus } : item
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
const statusLabels: Record<WorkOrderItemStatus, string> = {
|
||||
waiting: '대기',
|
||||
in_progress: '작업중',
|
||||
completed: '완료',
|
||||
};
|
||||
toast.success(`품목 상태가 '${statusLabels[newStatus]}'(으)로 변경되었습니다.`);
|
||||
|
||||
// 작업지시 상태가 변경된 경우 추가 알림
|
||||
if (result.workOrderStatusChanged && result.workOrderStatus) {
|
||||
const workOrderStatusLabel = WORK_ORDER_STATUS_LABELS[result.workOrderStatus as keyof typeof WORK_ORDER_STATUS_LABELS] || result.workOrderStatus;
|
||||
toast.info(`작업지시 상태가 '${workOrderStatusLabel}'(으)로 자동 변경되었습니다.`);
|
||||
}
|
||||
} else {
|
||||
toast.error(result.error || '품목 상태 변경에 실패했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[WorkOrderDetail] handleItemStatusChange error:', error);
|
||||
toast.error('품목 상태 변경 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
setUpdatingItemId(null);
|
||||
}
|
||||
}, [order, orderId]);
|
||||
|
||||
// 로딩 상태
|
||||
if (isLoading) {
|
||||
return (
|
||||
<PageLayout>
|
||||
<h1 className="text-2xl font-bold mb-6">작업지시 상세</h1>
|
||||
<ContentLoadingSpinner text="작업지시 정보를 불러오는 중..." />
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
@@ -249,7 +334,9 @@ export function WorkOrderDetail({ orderId }: WorkOrderDetailProps) {
|
||||
quantity: order.items.reduce((sum, item) => sum + item.quantity, 0),
|
||||
progress: order.currentStep * 20, // 대략적인 진행률
|
||||
process: order.processType as 'screen' | 'slat' | 'bending',
|
||||
assignees: [order.assignee],
|
||||
assignees: order.assignees && order.assignees.length > 0
|
||||
? order.assignees.map(a => a.name)
|
||||
: [order.assignee],
|
||||
instruction: order.note || '',
|
||||
status: 'in_progress' as const,
|
||||
priority: order.priority <= 3 ? 'high' : order.priority <= 6 ? 'medium' : 'low',
|
||||
@@ -261,6 +348,35 @@ export function WorkOrderDetail({ orderId }: WorkOrderDetailProps) {
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1 className="text-2xl font-bold">작업지시 상세</h1>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* 상태 변경 버튼 */}
|
||||
{order.status === 'waiting' && (
|
||||
<Button
|
||||
onClick={() => handleStatusChange('in_progress')}
|
||||
disabled={isStatusUpdating}
|
||||
className="bg-green-600 hover:bg-green-700"
|
||||
>
|
||||
{isStatusUpdating ? (
|
||||
<Loader2 className="w-4 h-4 mr-1.5 animate-spin" />
|
||||
) : (
|
||||
<Play className="w-4 h-4 mr-1.5" />
|
||||
)}
|
||||
작업 시작
|
||||
</Button>
|
||||
)}
|
||||
{order.status === 'in_progress' && (
|
||||
<Button
|
||||
onClick={() => handleStatusChange('completed')}
|
||||
disabled={isStatusUpdating}
|
||||
className="bg-purple-600 hover:bg-purple-700"
|
||||
>
|
||||
{isStatusUpdating ? (
|
||||
<Loader2 className="w-4 h-4 mr-1.5 animate-spin" />
|
||||
) : (
|
||||
<CheckCircle2 className="w-4 h-4 mr-1.5" />
|
||||
)}
|
||||
작업 완료
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="outline" onClick={() => setIsWorkLogOpen(true)}>
|
||||
<FileText className="w-4 h-4 mr-1.5" />
|
||||
작업일지
|
||||
@@ -287,7 +403,7 @@ export function WorkOrderDetail({ orderId }: WorkOrderDetailProps) {
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground mb-1">공정구분</p>
|
||||
<p className="font-medium">{PROCESS_TYPE_LABELS[order.processType]}</p>
|
||||
<p className="font-medium">{order.processName}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground mb-1">작업상태</p>
|
||||
@@ -309,13 +425,21 @@ export function WorkOrderDetail({ orderId }: WorkOrderDetailProps) {
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground mb-1">작업자</p>
|
||||
<p className="font-medium">{order.assignee}</p>
|
||||
<p className="font-medium">
|
||||
{order.assignees && order.assignees.length > 0
|
||||
? order.assignees.map(a => a.name).join(', ')
|
||||
: order.assignee}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 공정 진행 */}
|
||||
<ProcessSteps processType={order.processType} currentStep={order.currentStep} />
|
||||
<ProcessSteps
|
||||
processType={order.processType}
|
||||
currentStep={order.currentStep}
|
||||
workSteps={order.workSteps}
|
||||
/>
|
||||
|
||||
{/* 작업 품목 */}
|
||||
<div className="bg-white border rounded-lg p-6">
|
||||
@@ -346,14 +470,32 @@ export function WorkOrderDetail({ orderId }: WorkOrderDetailProps) {
|
||||
<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
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleItemStatusChange(item.id, 'in_progress')}
|
||||
disabled={updatingItemId === item.id}
|
||||
>
|
||||
{updatingItemId === item.id ? (
|
||||
<Loader2 className="w-3 h-3 mr-1 animate-spin" />
|
||||
) : (
|
||||
<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
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleItemStatusChange(item.id, 'completed')}
|
||||
disabled={updatingItemId === item.id}
|
||||
>
|
||||
{updatingItemId === item.id ? (
|
||||
<Loader2 className="w-3 h-3 mr-1 animate-spin" />
|
||||
) : (
|
||||
<CheckCircle2 className="w-3 h-3 mr-1" />
|
||||
)}
|
||||
완료
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@@ -23,7 +23,6 @@ import { ListMobileCard, InfoField } from '@/components/organisms/ListMobileCard
|
||||
import { toast } from 'sonner';
|
||||
import { getWorkOrders, getWorkOrderStats } from './actions';
|
||||
import {
|
||||
PROCESS_TYPE_LABELS,
|
||||
WORK_ORDER_STATUS_LABELS,
|
||||
WORK_ORDER_STATUS_COLORS,
|
||||
type WorkOrder,
|
||||
@@ -31,6 +30,18 @@ import {
|
||||
} from './types';
|
||||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||
|
||||
// Debounce 훅
|
||||
function useDebounce<T>(value: T, delay: number): T {
|
||||
const [debouncedValue, setDebouncedValue] = useState<T>(value);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setDebouncedValue(value), delay);
|
||||
return () => clearTimeout(timer);
|
||||
}, [value, delay]);
|
||||
|
||||
return debouncedValue;
|
||||
}
|
||||
|
||||
// 탭 필터 정의
|
||||
type TabFilter = 'all' | 'unassigned' | 'pending' | 'waiting' | 'in_progress' | 'completed';
|
||||
|
||||
@@ -44,6 +55,9 @@ export function WorkOrderList() {
|
||||
const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set());
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
|
||||
// 디바운스된 검색어 (300ms 딜레이)
|
||||
const debouncedSearchTerm = useDebounce(searchTerm, 300);
|
||||
|
||||
// API 데이터 상태
|
||||
const [workOrders, setWorkOrders] = useState<WorkOrder[]>([]);
|
||||
const [statsData, setStatsData] = useState<WorkOrderStats>({
|
||||
@@ -58,45 +72,56 @@ export function WorkOrderList() {
|
||||
const [totalItems, setTotalItems] = useState(0);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
|
||||
// 데이터 로드
|
||||
const loadData = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
// 목록과 통계를 병렬로 조회
|
||||
const [listResult, statsResult] = await Promise.all([
|
||||
getWorkOrders({
|
||||
page: currentPage,
|
||||
perPage: ITEMS_PER_PAGE,
|
||||
status: activeTab === 'all' ? undefined : activeTab,
|
||||
search: searchTerm || undefined,
|
||||
}),
|
||||
getWorkOrderStats(),
|
||||
]);
|
||||
|
||||
if (listResult.success) {
|
||||
setWorkOrders(listResult.data);
|
||||
setTotalItems(listResult.pagination.total);
|
||||
setTotalPages(listResult.pagination.lastPage);
|
||||
} else {
|
||||
toast.error(listResult.error || '목록 조회에 실패했습니다.');
|
||||
}
|
||||
|
||||
if (statsResult.success && statsResult.data) {
|
||||
setStatsData(statsResult.data);
|
||||
}
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('[WorkOrderList] loadData error:', error);
|
||||
toast.error('데이터 로드 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [currentPage, activeTab, searchTerm]);
|
||||
|
||||
// 초기 로드 및 필터 변경 시 데이터 다시 로드
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
|
||||
const loadData = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
// 목록과 통계를 병렬로 조회
|
||||
const [listResult, statsResult] = await Promise.all([
|
||||
getWorkOrders({
|
||||
page: currentPage,
|
||||
perPage: ITEMS_PER_PAGE,
|
||||
status: activeTab === 'all' ? undefined : activeTab,
|
||||
search: debouncedSearchTerm || undefined,
|
||||
}),
|
||||
getWorkOrderStats(),
|
||||
]);
|
||||
|
||||
// 컴포넌트가 언마운트되었으면 상태 업데이트 중단
|
||||
if (!isMounted) return;
|
||||
|
||||
if (listResult.success) {
|
||||
setWorkOrders(listResult.data);
|
||||
setTotalItems(listResult.pagination.total);
|
||||
setTotalPages(listResult.pagination.lastPage);
|
||||
} else {
|
||||
toast.error(listResult.error || '목록 조회에 실패했습니다.');
|
||||
}
|
||||
|
||||
if (statsResult.success && statsResult.data) {
|
||||
setStatsData(statsResult.data);
|
||||
}
|
||||
} catch (error) {
|
||||
if (!isMounted) return;
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('[WorkOrderList] loadData error:', error);
|
||||
toast.error('데이터 로드 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
if (isMounted) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
loadData();
|
||||
}, [loadData]);
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, [currentPage, activeTab, debouncedSearchTerm]);
|
||||
|
||||
// 탭 옵션 (통계 데이터 기반)
|
||||
const tabs: TabOption[] = [
|
||||
@@ -224,7 +249,7 @@ export function WorkOrderList() {
|
||||
</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.processName}</TableCell>
|
||||
<TableCell>{order.lotNo}</TableCell>
|
||||
<TableCell>{order.orderDate}</TableCell>
|
||||
<TableCell className="text-center">{order.isAssigned ? 'Y' : '-'}</TableCell>
|
||||
@@ -273,7 +298,7 @@ export function WorkOrderList() {
|
||||
}
|
||||
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.processName} />
|
||||
<InfoField label="로트번호" value={order.lotNo} />
|
||||
<InfoField label="발주처" value={order.client} />
|
||||
<InfoField label="작업자" value={order.assignee || '-'} />
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
* - PATCH /api/v1/work-orders/{id}/bending/toggle - 벤딩 필드 토글
|
||||
* - POST /api/v1/work-orders/{id}/issues - 이슈 등록
|
||||
* - PATCH /api/v1/work-orders/{id}/issues/{issueId}/resolve - 이슈 해결
|
||||
* - PATCH /api/v1/work-orders/{id}/items/{itemId}/status - 품목 상태 변경
|
||||
*/
|
||||
|
||||
'use server';
|
||||
@@ -24,7 +25,6 @@ import type {
|
||||
WorkOrder,
|
||||
WorkOrderStats,
|
||||
WorkOrderStatus,
|
||||
ProcessType,
|
||||
WorkOrderApiPaginatedResponse,
|
||||
WorkOrderStatsApi,
|
||||
} from './types';
|
||||
@@ -47,7 +47,7 @@ export async function getWorkOrders(params?: {
|
||||
page?: number;
|
||||
perPage?: number;
|
||||
status?: WorkOrderStatus | 'all';
|
||||
processType?: ProcessType | 'all';
|
||||
processId?: number | 'all'; // 공정 ID (FK → processes.id)
|
||||
search?: string;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
@@ -71,8 +71,8 @@ export async function getWorkOrders(params?: {
|
||||
if (params?.status && params.status !== 'all') {
|
||||
searchParams.set('status', params.status);
|
||||
}
|
||||
if (params?.processType && params.processType !== 'all') {
|
||||
searchParams.set('process_type', params.processType);
|
||||
if (params?.processId && params.processId !== 'all') {
|
||||
searchParams.set('process_id', String(params.processId));
|
||||
}
|
||||
if (params?.search) searchParams.set('search', params.search);
|
||||
if (params?.startDate) searchParams.set('start_date', params.startDate);
|
||||
@@ -220,15 +220,23 @@ export async function getWorkOrderById(id: string): Promise<{
|
||||
export async function createWorkOrder(
|
||||
data: Partial<WorkOrder> & {
|
||||
salesOrderId?: number;
|
||||
assigneeId?: number;
|
||||
assigneeId?: number; // 단일 담당자 (하위 호환)
|
||||
assigneeIds?: number[]; // 다중 담당자
|
||||
teamId?: number;
|
||||
}
|
||||
): Promise<{ success: boolean; data?: WorkOrder; error?: string }> {
|
||||
try {
|
||||
// 다중 담당자 우선, 없으면 단일 담당자 배열로 변환
|
||||
const assigneeIds = data.assigneeIds && data.assigneeIds.length > 0
|
||||
? data.assigneeIds
|
||||
: data.assigneeId
|
||||
? [data.assigneeId]
|
||||
: undefined;
|
||||
|
||||
const apiData = {
|
||||
...transformFrontendToApi(data),
|
||||
sales_order_id: data.salesOrderId,
|
||||
assignee_id: data.assigneeId,
|
||||
assignee_ids: assigneeIds, // 배열로 전송
|
||||
team_id: data.teamId,
|
||||
};
|
||||
|
||||
@@ -384,11 +392,13 @@ export async function updateWorkOrderStatus(
|
||||
// ===== 담당자 배정 =====
|
||||
export async function assignWorkOrder(
|
||||
id: string,
|
||||
assigneeId: number,
|
||||
assigneeIds: number | number[], // 단일 또는 다중 담당자
|
||||
teamId?: number
|
||||
): Promise<{ success: boolean; data?: WorkOrder; error?: string }> {
|
||||
try {
|
||||
const body: { assignee_id: number; team_id?: number } = { assignee_id: assigneeId };
|
||||
// 배열로 통일
|
||||
const ids = Array.isArray(assigneeIds) ? assigneeIds : [assigneeIds];
|
||||
const body: { assignee_ids: number[]; team_id?: number } = { assignee_ids: ids };
|
||||
if (teamId) body.team_id = teamId;
|
||||
|
||||
console.log('[WorkOrderActions] PATCH assign request:', body);
|
||||
@@ -550,6 +560,62 @@ export async function resolveWorkOrderIssue(
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 품목 상태 변경 =====
|
||||
export type WorkOrderItemStatus = 'waiting' | 'in_progress' | 'completed';
|
||||
|
||||
export async function updateWorkOrderItemStatus(
|
||||
workOrderId: string,
|
||||
itemId: number,
|
||||
status: WorkOrderItemStatus
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
itemId: number;
|
||||
status: WorkOrderItemStatus;
|
||||
workOrderStatus?: string;
|
||||
workOrderStatusChanged?: boolean;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
console.log('[WorkOrderActions] PATCH item status request:', { workOrderId, itemId, status });
|
||||
|
||||
const { response, error } = await serverFetch(
|
||||
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/work-orders/${workOrderId}/items/${itemId}/status`,
|
||||
{
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ status }),
|
||||
}
|
||||
);
|
||||
|
||||
if (error || !response) {
|
||||
return { success: false, itemId, status, error: error?.message || 'API 요청 실패' };
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
console.log('[WorkOrderActions] PATCH item status response:', result);
|
||||
|
||||
if (!response.ok || !result.success) {
|
||||
return {
|
||||
success: false,
|
||||
itemId,
|
||||
status,
|
||||
error: result.message || '품목 상태 변경에 실패했습니다.',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
itemId,
|
||||
status: result.data?.item?.status || status,
|
||||
workOrderStatus: result.data?.work_order_status,
|
||||
workOrderStatusChanged: result.data?.work_order_status_changed || false,
|
||||
};
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('[WorkOrderActions] updateWorkOrderItemStatus error:', error);
|
||||
return { success: false, itemId, status, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 수주 목록 조회 (작업지시 생성용) =====
|
||||
export interface SalesOrderForWorkOrder {
|
||||
id: number;
|
||||
@@ -579,9 +645,9 @@ export async function getSalesOrdersForWorkOrder(params?: {
|
||||
if (params?.status) searchParams.set('status', params.status);
|
||||
|
||||
const queryString = searchParams.toString();
|
||||
const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/sales-orders${queryString ? `?${queryString}` : ''}`;
|
||||
const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/orders${queryString ? `?${queryString}` : ''}`;
|
||||
|
||||
console.log('[WorkOrderActions] GET sales-orders for work-order:', url);
|
||||
console.log('[WorkOrderActions] GET orders for work-order:', url);
|
||||
|
||||
const { response, error } = await serverFetch(url, { method: 'GET' });
|
||||
|
||||
@@ -590,7 +656,7 @@ export async function getSalesOrdersForWorkOrder(params?: {
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
console.warn('[WorkOrderActions] GET sales-orders error:', response.status);
|
||||
console.warn('[WorkOrderActions] GET orders error:', response.status);
|
||||
return { success: false, data: [], error: `API 오류: ${response.status}` };
|
||||
}
|
||||
|
||||
@@ -717,3 +783,65 @@ export async function getDepartmentsWithUsers(): Promise<{
|
||||
return { success: false, data: [], error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 공정 목록 조회 (작업지시 생성용) =====
|
||||
export interface ProcessOption {
|
||||
id: number;
|
||||
processCode: string;
|
||||
processName: string;
|
||||
}
|
||||
|
||||
export async function getProcessOptions(): Promise<{
|
||||
success: boolean;
|
||||
data: ProcessOption[];
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/processes/options`;
|
||||
|
||||
console.log('[WorkOrderActions] GET process options:', url);
|
||||
|
||||
const { response, error } = await serverFetch(url, { method: 'GET' });
|
||||
|
||||
if (error || !response) {
|
||||
return { success: false, data: [], error: error?.message || 'API 요청 실패' };
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
console.warn('[WorkOrderActions] GET process options error:', response.status);
|
||||
return { success: false, data: [], error: `API 오류: ${response.status}` };
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!result.success) {
|
||||
return {
|
||||
success: false,
|
||||
data: [],
|
||||
error: result.message || '공정 목록 조회에 실패했습니다.',
|
||||
};
|
||||
}
|
||||
|
||||
// API 응답 변환
|
||||
const processes: ProcessOption[] = (result.data || []).map(
|
||||
(item: {
|
||||
id: number;
|
||||
process_code: string;
|
||||
process_name: string;
|
||||
}) => ({
|
||||
id: item.id,
|
||||
processCode: item.process_code,
|
||||
processName: item.process_name,
|
||||
})
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: processes,
|
||||
};
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('[WorkOrderActions] getProcessOptions error:', error);
|
||||
return { success: false, data: [], error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,15 @@
|
||||
* 작업지시 관리 타입 정의
|
||||
*/
|
||||
|
||||
// 공정 구분
|
||||
// 공정 정보 (API 관계)
|
||||
export interface ProcessInfo {
|
||||
id: number;
|
||||
process_code: string;
|
||||
process_name: string;
|
||||
}
|
||||
|
||||
// @deprecated process_type은 process_id FK로 변경됨
|
||||
// 하위 호환성을 위해 유지
|
||||
export type ProcessType = 'screen' | 'slat' | 'bending';
|
||||
|
||||
export const PROCESS_TYPE_LABELS: Record<ProcessType, string> = {
|
||||
@@ -134,22 +142,36 @@ export const ISSUE_STATUS_LABELS: Record<WorkOrderIssue['status'], string> = {
|
||||
resolved: '해결됨',
|
||||
};
|
||||
|
||||
// 공정 단계 타입
|
||||
export interface ProcessStep {
|
||||
key: string;
|
||||
label: string;
|
||||
order: number;
|
||||
}
|
||||
|
||||
// 작업지시 메인 타입
|
||||
export interface WorkOrder {
|
||||
id: string;
|
||||
workOrderNo: string; // 작업지시번호 (KD-WO-251217-12)
|
||||
lotNo: string; // 로트번호 (KD-TS-251217-10)
|
||||
processType: ProcessType; // 공정구분
|
||||
processId: number; // 공정 ID (FK)
|
||||
processName: string; // 공정명 (표시용)
|
||||
processCode: string; // 공정코드 (표시용)
|
||||
workSteps?: ProcessStep[]; // 공정 단계 (동적, DB에서 로드)
|
||||
/** @deprecated process_id FK 사용 */
|
||||
processType: ProcessType; // 하위 호환용
|
||||
status: WorkOrderStatus; // 작업상태
|
||||
|
||||
// 기본 정보
|
||||
client: string; // 발주처
|
||||
projectName: string; // 현장명
|
||||
dueDate: string; // 납기일
|
||||
assignee: string; // 작업자
|
||||
assignee: string; // 작업자 (주 담당자)
|
||||
assignees?: { id: string; name: string; isPrimary: boolean }[]; // 다중 담당자
|
||||
|
||||
// 날짜 정보
|
||||
orderDate: string; // 지시일
|
||||
scheduledDate: string; // 예정일 (API: scheduled_date)
|
||||
shipmentDate: string; // 출고예정일
|
||||
|
||||
// 플래그
|
||||
@@ -237,6 +259,17 @@ export interface WorkOrderBendingDetailApi {
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
// API 응답 - 담당자 (다중 담당자)
|
||||
export interface WorkOrderAssigneeApi {
|
||||
id: number;
|
||||
work_order_id: number;
|
||||
user_id: number;
|
||||
is_primary: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
user?: { id: number; name: string };
|
||||
}
|
||||
|
||||
// API 응답 - 이슈
|
||||
export interface WorkOrderIssueApi {
|
||||
id: number;
|
||||
@@ -259,7 +292,7 @@ export interface WorkOrderApi {
|
||||
work_order_no: string;
|
||||
sales_order_id: number | null;
|
||||
project_name: string | null;
|
||||
process_type: 'screen' | 'slat' | 'bending';
|
||||
process_id: number; // FK to processes.id
|
||||
status: 'unassigned' | 'pending' | 'waiting' | 'in_progress' | 'completed' | 'shipped';
|
||||
assignee_id: number | null;
|
||||
team_id: number | null;
|
||||
@@ -277,7 +310,14 @@ export interface WorkOrderApi {
|
||||
order_no: string;
|
||||
client?: { id: number; name: string };
|
||||
};
|
||||
process?: {
|
||||
id: number;
|
||||
process_code: string;
|
||||
process_name: string;
|
||||
work_steps?: string[] | { key: string; label: string; order: number }[];
|
||||
};
|
||||
assignee?: { id: number; name: string };
|
||||
assignees?: WorkOrderAssigneeApi[];
|
||||
team?: { id: number; name: string };
|
||||
items?: WorkOrderItemApi[];
|
||||
bending_detail?: WorkOrderBendingDetailApi;
|
||||
@@ -308,19 +348,53 @@ export interface WorkOrderStatsApi {
|
||||
|
||||
// API → Frontend 변환
|
||||
export function transformApiToFrontend(api: WorkOrderApi): WorkOrder {
|
||||
// 다중 담당자 변환
|
||||
const assignees = (api.assignees || []).map(a => ({
|
||||
id: String(a.user_id),
|
||||
name: a.user?.name || '-',
|
||||
isPrimary: a.is_primary,
|
||||
}));
|
||||
|
||||
// 주 담당자 이름 (기존 호환)
|
||||
const primaryAssignee = assignees.find(a => a.isPrimary);
|
||||
const assigneeName = primaryAssignee?.name || api.assignee?.name || '-';
|
||||
|
||||
// 공정명 → 하위호환용 processType 매핑
|
||||
const processNameToType = (name: string): ProcessType => {
|
||||
const mapping: Record<string, ProcessType> = {
|
||||
'스크린': 'screen',
|
||||
'슬랫': 'slat',
|
||||
'절곡': 'bending',
|
||||
};
|
||||
return mapping[name] || 'screen';
|
||||
};
|
||||
|
||||
return {
|
||||
id: String(api.id),
|
||||
workOrderNo: api.work_order_no,
|
||||
lotNo: api.sales_order?.order_no || '-',
|
||||
processType: api.process_type,
|
||||
processId: api.process_id,
|
||||
processName: api.process?.process_name || '-',
|
||||
processCode: api.process?.process_code || '-',
|
||||
// work_steps: string[] 또는 ProcessStep[] 형식 모두 지원
|
||||
workSteps: api.process?.work_steps
|
||||
? (api.process.work_steps as (string | { key: string; label: string; order: number })[]).map((step, idx) =>
|
||||
typeof step === 'string'
|
||||
? { key: `step-${idx}`, label: step, order: idx + 1 }
|
||||
: step
|
||||
)
|
||||
: undefined,
|
||||
processType: processNameToType(api.process?.process_name || ''), // 하위 호환
|
||||
status: api.status,
|
||||
client: api.sales_order?.client?.name || '-',
|
||||
projectName: api.project_name || '-',
|
||||
dueDate: api.scheduled_date || '-',
|
||||
assignee: api.assignee?.name || '-',
|
||||
assignee: assigneeName,
|
||||
assignees: assignees.length > 0 ? assignees : undefined,
|
||||
orderDate: api.created_at.split('T')[0],
|
||||
scheduledDate: api.scheduled_date || '',
|
||||
shipmentDate: api.scheduled_date || '-',
|
||||
isAssigned: api.assignee_id !== null,
|
||||
isAssigned: api.assignee_id !== null || assignees.length > 0,
|
||||
isStarted: ['in_progress', 'completed', 'shipped'].includes(api.status),
|
||||
priority: 5, // Default priority
|
||||
currentStep: getStatusStep(api.status),
|
||||
@@ -387,13 +461,14 @@ function getStatusStep(status: WorkOrderStatus): number {
|
||||
}
|
||||
|
||||
// Frontend → API 변환 (등록/수정용)
|
||||
export function transformFrontendToApi(data: Partial<WorkOrder>): Record<string, unknown> {
|
||||
export function transformFrontendToApi(data: Partial<WorkOrder> & { processId?: number }): Record<string, unknown> {
|
||||
const result: Record<string, unknown> = {};
|
||||
|
||||
if (data.projectName !== undefined) result.project_name = data.projectName;
|
||||
if (data.processType !== undefined) result.process_type = data.processType;
|
||||
if (data.processId !== undefined) result.process_id = data.processId;
|
||||
if (data.status !== undefined) result.status = data.status;
|
||||
if (data.dueDate !== undefined) result.scheduled_date = data.dueDate;
|
||||
if (data.scheduledDate !== undefined) result.scheduled_date = data.scheduledDate;
|
||||
if (data.dueDate !== undefined && data.scheduledDate === undefined) result.scheduled_date = data.dueDate;
|
||||
if (data.note !== undefined) result.memo = data.note;
|
||||
|
||||
// items 변환
|
||||
|
||||
@@ -69,6 +69,7 @@ export interface QuoteItem {
|
||||
id: string;
|
||||
quoteId: string;
|
||||
productId?: string;
|
||||
itemCode?: string; // 품목코드 (item_code)
|
||||
productName: string;
|
||||
specification?: string;
|
||||
unit?: string;
|
||||
@@ -298,6 +299,7 @@ export function transformItemApiToFrontend(apiData: QuoteItemApiData): QuoteItem
|
||||
id: String(apiData.id),
|
||||
quoteId: String(apiData.quote_id),
|
||||
productId: apiData.item_id ? String(apiData.item_id) : (apiData.product_id ? String(apiData.product_id) : undefined),
|
||||
itemCode: apiData.item_code || undefined, // 품목코드
|
||||
productName,
|
||||
specification: apiData.specification || undefined,
|
||||
unit: apiData.unit || undefined,
|
||||
@@ -545,7 +547,7 @@ export function transformQuoteToFormData(quote: Quote): QuoteFormData {
|
||||
? quote.items.map((item, index) => ({
|
||||
itemIndex: index,
|
||||
finishedGoodsCode: '',
|
||||
itemCode: item.productId || item.id || '',
|
||||
itemCode: item.itemCode || '', // 품목코드 사용
|
||||
itemName: item.productName,
|
||||
itemType: '',
|
||||
itemCategory: '',
|
||||
|
||||
178
src/lib/actions/fcm.ts
Normal file
178
src/lib/actions/fcm.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* FCM 푸시 알림 공통 서버 액션
|
||||
*
|
||||
* 사용 예시:
|
||||
* import { sendFcmNotification, sendApprovalNotification } from '@/lib/actions/fcm';
|
||||
*
|
||||
* // 기본 알림 발송
|
||||
* const result = await sendFcmNotification({ title: '알림', body: '내용' });
|
||||
*
|
||||
* // 결재 알림 발송 (프리셋)
|
||||
* const result = await sendApprovalNotification();
|
||||
*/
|
||||
|
||||
'use server';
|
||||
|
||||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||
import { serverFetch } from '@/lib/api/fetch-wrapper';
|
||||
|
||||
// ============================================
|
||||
// 타입 정의
|
||||
// ============================================
|
||||
|
||||
export interface FcmNotificationParams {
|
||||
/** 알림 제목 (필수) */
|
||||
title: string;
|
||||
/** 알림 본문 (필수) */
|
||||
body: string;
|
||||
/** 특정 테넌트에게만 발송 */
|
||||
tenant_id?: number;
|
||||
/** 특정 사용자에게만 발송 */
|
||||
user_id?: number;
|
||||
/** 플랫폼 필터 (android, ios, web) */
|
||||
platform?: 'android' | 'ios' | 'web';
|
||||
/** 알림 채널 ID (Android) */
|
||||
channel_id?: string;
|
||||
/** 알림 타입 (앱에서 분기 처리용) */
|
||||
type?: string;
|
||||
/** 클릭 시 이동할 URL */
|
||||
url?: string;
|
||||
/** 알림 사운드 키 */
|
||||
sound_key?: string;
|
||||
}
|
||||
|
||||
export interface FcmResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
sentCount?: number;
|
||||
__authError?: boolean;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 기본 FCM 발송 함수
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* FCM 푸시 알림 발송
|
||||
*/
|
||||
export async function sendFcmNotification(
|
||||
params: FcmNotificationParams
|
||||
): Promise<FcmResult> {
|
||||
try {
|
||||
const { response, error } = await serverFetch(
|
||||
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/admin/fcm/send`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify(params),
|
||||
}
|
||||
);
|
||||
|
||||
if (error?.__authError) {
|
||||
return { success: false, error: '인증이 필요합니다.', __authError: true };
|
||||
}
|
||||
|
||||
if (!response) {
|
||||
return { success: false, error: error?.message || 'FCM 발송에 실패했습니다.' };
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!response.ok || !result.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: result.message || 'FCM 발송에 실패했습니다.',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
sentCount: result.data?.success || 0,
|
||||
};
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('[FCM] sendFcmNotification error:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: '서버 오류가 발생했습니다.',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 프리셋 함수들 (자주 사용하는 알림 타입)
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* 결재 알림 발송 (프리셋)
|
||||
*/
|
||||
export async function sendApprovalNotification(
|
||||
customParams?: Partial<FcmNotificationParams>
|
||||
): Promise<FcmResult> {
|
||||
return sendFcmNotification({
|
||||
title: '결재 알림',
|
||||
body: '결재 문서가 완료되었습니다.',
|
||||
type: 'approval',
|
||||
channel_id: 'push_payment',
|
||||
...customParams,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 작업지시 알림 발송 (프리셋)
|
||||
*/
|
||||
export async function sendWorkOrderNotification(
|
||||
customParams?: Partial<FcmNotificationParams>
|
||||
): Promise<FcmResult> {
|
||||
return sendFcmNotification({
|
||||
title: '작업지시 알림',
|
||||
body: '새로운 작업지시가 있습니다.',
|
||||
type: 'work_order',
|
||||
channel_id: 'work_order',
|
||||
...customParams,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 일반 공지 알림 발송 (프리셋)
|
||||
*/
|
||||
export async function sendNoticeNotification(
|
||||
customParams?: Partial<FcmNotificationParams>
|
||||
): Promise<FcmResult> {
|
||||
return sendFcmNotification({
|
||||
title: '공지사항',
|
||||
body: '새로운 공지사항이 있습니다.',
|
||||
type: 'notice',
|
||||
channel_id: 'notice',
|
||||
...customParams,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 신규업체 알림 발송 (프리셋)
|
||||
*/
|
||||
export async function sendNewClientNotification(
|
||||
customParams?: Partial<FcmNotificationParams>
|
||||
): Promise<FcmResult> {
|
||||
return sendFcmNotification({
|
||||
title: '신규업체 알림',
|
||||
body: '새로운 업체가 등록되었습니다.',
|
||||
type: 'new_client',
|
||||
channel_id: 'push_urgent',
|
||||
...customParams,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 수주 알림 발송 (프리셋)
|
||||
*/
|
||||
export async function sendSalesOrderNotification(
|
||||
customParams?: Partial<FcmNotificationParams>
|
||||
): Promise<FcmResult> {
|
||||
return sendFcmNotification({
|
||||
title: '수주 알림',
|
||||
body: '수주가 완료되었습니다.',
|
||||
type: 'sales_order',
|
||||
channel_id: 'push_sales_order',
|
||||
...customParams,
|
||||
});
|
||||
}
|
||||
@@ -102,9 +102,16 @@ export class ApiClient {
|
||||
|
||||
/**
|
||||
* GET 요청
|
||||
* @param endpoint API 엔드포인트
|
||||
* @param options 쿼리 파라미터 등 옵션
|
||||
*/
|
||||
async get<T>(endpoint: string): Promise<T> {
|
||||
return this.request<T>(endpoint, { method: 'GET' });
|
||||
async get<T>(endpoint: string, options?: { params?: Record<string, string> }): Promise<T> {
|
||||
let url = endpoint;
|
||||
if (options?.params) {
|
||||
const searchParams = new URLSearchParams(options.params);
|
||||
url = `${endpoint}?${searchParams.toString()}`;
|
||||
}
|
||||
return this.request<T>(url, { method: 'GET' });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -128,10 +135,25 @@ export class ApiClient {
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE 요청
|
||||
* PATCH 요청
|
||||
*/
|
||||
async delete<T>(endpoint: string): Promise<T> {
|
||||
return this.request<T>(endpoint, { method: 'DELETE' });
|
||||
async patch<T>(endpoint: string, data?: unknown): Promise<T> {
|
||||
return this.request<T>(endpoint, {
|
||||
method: 'PATCH',
|
||||
body: data ? JSON.stringify(data) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE 요청
|
||||
* @param endpoint API 엔드포인트
|
||||
* @param options body 데이터 (일괄 삭제 등에서 사용)
|
||||
*/
|
||||
async delete<T>(endpoint: string, options?: { data?: unknown }): Promise<T> {
|
||||
return this.request<T>(endpoint, {
|
||||
method: 'DELETE',
|
||||
body: options?.data ? JSON.stringify(options.data) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
121
src/lib/api/common-codes.ts
Normal file
121
src/lib/api/common-codes.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
'use server';
|
||||
|
||||
import { apiClient } from './index';
|
||||
|
||||
// ========================================
|
||||
// 공통 코드 타입
|
||||
// ========================================
|
||||
|
||||
export interface CommonCode {
|
||||
id: number;
|
||||
code: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
sort_order: number;
|
||||
attributes: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 공통 코드 조회 함수
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* 특정 그룹의 공통 코드 목록 조회
|
||||
* GET /api/v1/settings/common/{group}
|
||||
*/
|
||||
export async function getCommonCodes(group: string): Promise<{
|
||||
success: boolean;
|
||||
data?: CommonCode[];
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
const response = await apiClient.get<CommonCode[]>(`/settings/common/${group}`);
|
||||
return { success: true, data: response };
|
||||
} catch (error) {
|
||||
console.error(`공통코드 조회 오류 (${group}):`, error);
|
||||
return { success: false, error: '공통코드를 불러오는데 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 공통 코드 옵션 형태로 변환
|
||||
* Select/ComboBox 등에서 사용
|
||||
*/
|
||||
export async function getCommonCodeOptions(group: string): Promise<{
|
||||
success: boolean;
|
||||
data?: { value: string; label: string }[];
|
||||
error?: string;
|
||||
}> {
|
||||
const result = await getCommonCodes(group);
|
||||
|
||||
if (!result.success || !result.data) {
|
||||
return { success: false, error: result.error };
|
||||
}
|
||||
|
||||
const options = result.data.map((code) => ({
|
||||
value: code.code,
|
||||
label: code.name,
|
||||
}));
|
||||
|
||||
return { success: true, data: options };
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 자주 사용하는 코드 그룹 함수
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* 수주 상태 코드 조회
|
||||
*/
|
||||
export async function getOrderStatusCodes() {
|
||||
return getCommonCodes('order_status');
|
||||
}
|
||||
|
||||
/**
|
||||
* 수주 상태 옵션 조회
|
||||
*/
|
||||
export async function getOrderStatusOptions() {
|
||||
return getCommonCodeOptions('order_status');
|
||||
}
|
||||
|
||||
/**
|
||||
* 수주 유형 코드 조회
|
||||
*/
|
||||
export async function getOrderTypeCodes() {
|
||||
return getCommonCodes('order_type');
|
||||
}
|
||||
|
||||
/**
|
||||
* 수주 유형 옵션 조회
|
||||
*/
|
||||
export async function getOrderTypeOptions() {
|
||||
return getCommonCodeOptions('order_type');
|
||||
}
|
||||
|
||||
/**
|
||||
* 거래처 유형 코드 조회
|
||||
*/
|
||||
export async function getClientTypeCodes() {
|
||||
return getCommonCodes('client_type');
|
||||
}
|
||||
|
||||
/**
|
||||
* 거래처 유형 옵션 조회
|
||||
*/
|
||||
export async function getClientTypeOptions() {
|
||||
return getCommonCodeOptions('client_type');
|
||||
}
|
||||
|
||||
/**
|
||||
* 품목 유형 코드 조회
|
||||
*/
|
||||
export async function getItemTypeCodes() {
|
||||
return getCommonCodes('item_type');
|
||||
}
|
||||
|
||||
/**
|
||||
* 품목 유형 옵션 조회
|
||||
*/
|
||||
export async function getItemTypeOptions() {
|
||||
return getCommonCodeOptions('item_type');
|
||||
}
|
||||
250
src/lib/api/index.ts
Normal file
250
src/lib/api/index.ts
Normal file
@@ -0,0 +1,250 @@
|
||||
// lib/api/index.ts
|
||||
// API 클라이언트 배럴 익스포트
|
||||
|
||||
export { ApiClient, withTokenRefresh } from './client';
|
||||
export { serverFetch } from './fetch-wrapper';
|
||||
export { AUTH_CONFIG } from './auth/auth-config';
|
||||
|
||||
// 공통 코드 유틸리티
|
||||
export {
|
||||
getCommonCodes,
|
||||
getCommonCodeOptions,
|
||||
getOrderStatusCodes,
|
||||
getOrderStatusOptions,
|
||||
getOrderTypeCodes,
|
||||
getOrderTypeOptions,
|
||||
getClientTypeCodes,
|
||||
getClientTypeOptions,
|
||||
getItemTypeCodes,
|
||||
getItemTypeOptions,
|
||||
type CommonCode,
|
||||
} from './common-codes';
|
||||
|
||||
// Server-side API 클라이언트
|
||||
// 서버 액션에서 쿠키 기반 Bearer 토큰 자동 포함
|
||||
import { cookies } from 'next/headers';
|
||||
import { redirect } from 'next/navigation';
|
||||
import { AUTH_CONFIG } from './auth/auth-config';
|
||||
import { refreshAccessToken } from './refresh-token';
|
||||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||
|
||||
/**
|
||||
* Server Actions 전용 API 클라이언트
|
||||
*
|
||||
* 특징:
|
||||
* - 쿠키에서 access_token 자동 읽기
|
||||
* - X-API-KEY + Bearer 토큰 자동 포함
|
||||
* - 401 발생 시 토큰 자동 갱신 후 재시도
|
||||
*/
|
||||
class ServerApiClient {
|
||||
private baseURL: string;
|
||||
private apiKey: string;
|
||||
|
||||
constructor() {
|
||||
// API URL에 /api/v1 prefix 자동 추가
|
||||
const apiUrl = AUTH_CONFIG.apiUrl.replace(/\/$/, ''); // trailing slash 제거
|
||||
this.baseURL = `${apiUrl}/api/v1`;
|
||||
this.apiKey = process.env.API_KEY || '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 쿠키에서 인증 헤더 생성 (async)
|
||||
*/
|
||||
private async getAuthHeaders(token?: string): Promise<Record<string, string>> {
|
||||
const cookieStore = await cookies();
|
||||
const accessToken = token || cookieStore.get('access_token')?.value;
|
||||
|
||||
return {
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'X-API-KEY': this.apiKey,
|
||||
...(accessToken ? { 'Authorization': `Bearer ${accessToken}` } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 토큰 쿠키 삭제 (인증 실패 시)
|
||||
*/
|
||||
private async clearTokenCookies() {
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.delete('access_token');
|
||||
cookieStore.delete('refresh_token');
|
||||
cookieStore.delete('token_refreshed_at');
|
||||
cookieStore.delete('is_authenticated');
|
||||
console.log('🗑️ [ServerApiClient] 토큰 쿠키 삭제 완료');
|
||||
}
|
||||
|
||||
/**
|
||||
* 새 토큰을 쿠키에 저장
|
||||
*/
|
||||
private async setNewTokenCookies(tokens: {
|
||||
accessToken?: string;
|
||||
refreshToken?: string;
|
||||
expiresIn?: number;
|
||||
}) {
|
||||
const cookieStore = await cookies();
|
||||
const isProduction = process.env.NODE_ENV === 'production';
|
||||
|
||||
if (tokens.accessToken) {
|
||||
cookieStore.set('access_token', tokens.accessToken, {
|
||||
httpOnly: true,
|
||||
secure: isProduction,
|
||||
sameSite: 'lax',
|
||||
path: '/',
|
||||
maxAge: tokens.expiresIn || 7200,
|
||||
});
|
||||
|
||||
cookieStore.set('token_refreshed_at', Date.now().toString(), {
|
||||
httpOnly: false,
|
||||
secure: isProduction,
|
||||
sameSite: 'lax',
|
||||
path: '/',
|
||||
maxAge: 60,
|
||||
});
|
||||
}
|
||||
|
||||
if (tokens.refreshToken) {
|
||||
cookieStore.set('refresh_token', tokens.refreshToken, {
|
||||
httpOnly: true,
|
||||
secure: isProduction,
|
||||
sameSite: 'lax',
|
||||
path: '/',
|
||||
maxAge: 604800,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP 요청 실행 (토큰 자동 갱신 포함)
|
||||
*/
|
||||
private async request<T>(
|
||||
endpoint: string,
|
||||
options?: RequestInit & { skipAuthRetry?: boolean }
|
||||
): Promise<T> {
|
||||
try {
|
||||
const cookieStore = await cookies();
|
||||
const refreshToken = cookieStore.get('refresh_token')?.value;
|
||||
const headers = await this.getAuthHeaders();
|
||||
|
||||
const url = `${this.baseURL}${endpoint}`;
|
||||
let response = await fetch(url, {
|
||||
...options,
|
||||
headers: {
|
||||
...headers,
|
||||
...options?.headers,
|
||||
},
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
// 401 발생 시 토큰 갱신 후 재시도
|
||||
if (response.status === 401 && !options?.skipAuthRetry && refreshToken) {
|
||||
console.log('🔄 [ServerApiClient] 401 발생, 토큰 갱신 시도...');
|
||||
|
||||
const refreshResult = await refreshAccessToken(refreshToken, 'ServerApiClient');
|
||||
|
||||
if (refreshResult.success && refreshResult.accessToken) {
|
||||
console.log('✅ [ServerApiClient] 토큰 갱신 성공, 재시도...');
|
||||
await this.setNewTokenCookies(refreshResult);
|
||||
|
||||
const newHeaders = await this.getAuthHeaders(refreshResult.accessToken);
|
||||
response = await fetch(url, {
|
||||
...options,
|
||||
headers: {
|
||||
...newHeaders,
|
||||
...options?.headers,
|
||||
},
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
if (response.status === 401) {
|
||||
console.warn('🔴 [ServerApiClient] 재시도 실패, 로그인 리다이렉트');
|
||||
await this.clearTokenCookies();
|
||||
redirect('/login');
|
||||
}
|
||||
} else {
|
||||
console.warn('🔴 [ServerApiClient] 토큰 갱신 실패, 로그인 리다이렉트');
|
||||
await this.clearTokenCookies();
|
||||
redirect('/login');
|
||||
}
|
||||
} else if (response.status === 401 && !options?.skipAuthRetry) {
|
||||
console.warn('🔴 [ServerApiClient] 401 (refresh token 없음), 로그인 리다이렉트');
|
||||
await this.clearTokenCookies();
|
||||
redirect('/login');
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw {
|
||||
status: response.status,
|
||||
message: errorData.message || 'An error occurred',
|
||||
errors: errorData.errors,
|
||||
code: errorData.code,
|
||||
};
|
||||
}
|
||||
|
||||
if (response.status === 204) {
|
||||
return undefined as T;
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET 요청
|
||||
*/
|
||||
async get<T>(endpoint: string, options?: { params?: Record<string, string> }): Promise<T> {
|
||||
let url = endpoint;
|
||||
if (options?.params) {
|
||||
const searchParams = new URLSearchParams(options.params);
|
||||
url = `${endpoint}?${searchParams.toString()}`;
|
||||
}
|
||||
return this.request<T>(url, { method: 'GET' });
|
||||
}
|
||||
|
||||
/**
|
||||
* POST 요청
|
||||
*/
|
||||
async post<T>(endpoint: string, data?: unknown): Promise<T> {
|
||||
return this.request<T>(endpoint, {
|
||||
method: 'POST',
|
||||
body: data ? JSON.stringify(data) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT 요청
|
||||
*/
|
||||
async put<T>(endpoint: string, data?: unknown): Promise<T> {
|
||||
return this.request<T>(endpoint, {
|
||||
method: 'PUT',
|
||||
body: data ? JSON.stringify(data) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH 요청
|
||||
*/
|
||||
async patch<T>(endpoint: string, data?: unknown): Promise<T> {
|
||||
return this.request<T>(endpoint, {
|
||||
method: 'PATCH',
|
||||
body: data ? JSON.stringify(data) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE 요청
|
||||
*/
|
||||
async delete<T>(endpoint: string, options?: { data?: unknown }): Promise<T> {
|
||||
return this.request<T>(endpoint, {
|
||||
method: 'DELETE',
|
||||
body: options?.data ? JSON.stringify(options.data) : undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 서버 액션용 API 클라이언트 인스턴스
|
||||
export const apiClient = new ServerApiClient();
|
||||
@@ -17,6 +17,13 @@ export type RuleType = '품목코드' | '품목명' | '품목구분';
|
||||
// 매칭 방식
|
||||
export type MatchingType = 'startsWith' | 'endsWith' | 'contains' | 'equals';
|
||||
|
||||
// 개별 품목 정보
|
||||
export interface IndividualItem {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
// 자동 분류 규칙
|
||||
export interface ClassificationRule {
|
||||
id: string;
|
||||
@@ -28,6 +35,7 @@ export interface ClassificationRule {
|
||||
description?: string;
|
||||
isActive: boolean;
|
||||
createdAt: string;
|
||||
items?: IndividualItem[]; // 개별 품목인 경우 품목 정보
|
||||
}
|
||||
|
||||
// 자동 분류 규칙 입력용 (id, createdAt 제외)
|
||||
|
||||
@@ -6,6 +6,11 @@
|
||||
*/
|
||||
|
||||
export function formatAmount(amount: number): string {
|
||||
// NaN, undefined, null 처리
|
||||
if (amount == null || isNaN(amount)) {
|
||||
return "0원";
|
||||
}
|
||||
|
||||
if (amount < 10000) {
|
||||
return `${amount.toLocaleString("ko-KR")}원`;
|
||||
} else {
|
||||
@@ -18,6 +23,10 @@ export function formatAmount(amount: number): string {
|
||||
* 금액을 원 단위로 포맷 (항상 "원" 단위)
|
||||
*/
|
||||
export function formatAmountWon(amount: number): string {
|
||||
// NaN, undefined, null 처리
|
||||
if (amount == null || isNaN(amount)) {
|
||||
return "0원";
|
||||
}
|
||||
return `${amount.toLocaleString("ko-KR")}원`;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user