Merge branch 'master' into feature/universal-list-component

This commit is contained in:
byeongcheolryu
2026-01-14 15:30:57 +09:00
60 changed files with 9858 additions and 6344 deletions

View File

@@ -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 수정 완료
---

View 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 추가 고려

View File

@@ -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 구현 (진행 중)

View 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: 고급 기능 (현재)

View File

@@ -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 급여관리

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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">
&gt; .
&gt; .
</p>
</div>
</AlertDialogDescription>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -457,4 +457,4 @@ export async function cancelDraft(id: string): Promise<{ success: boolean; error
error: '서버 오류가 발생했습니다.',
};
}
}
}

View File

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

View File

@@ -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: '순서 변경에 실패했습니다.' };
}
}

View File

@@ -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: '일괄 삭제에 실패했습니다.' };
}
}

View File

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

View File

@@ -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: '일괄 삭제에 실패했습니다.' };
}
}

View File

@@ -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: '등록에 실패했습니다.' };
}
}

View File

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

View File

@@ -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: '노임 일괄 삭제에 실패했습니다.' };

View File

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

View File

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

View File

@@ -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: '일괄 삭제에 실패했습니다.' };
}
}

View File

@@ -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: '변경이력을 불러오는데 실패했습니다.' };
}
}

View File

@@ -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;
}
// ===== 필터 옵션 =====
// 품목유형 옵션

View File

@@ -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}
>
&quot;{attendeeSearchValue}&quot;
</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>
);
}

View File

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

View File

@@ -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: '일괄 삭제에 실패했습니다.' };
}
}

View File

@@ -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, // 기본값

View File

@@ -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: '현장 등록에 실패했습니다.' };
}
}

View File

@@ -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; // 보류 현장
}
// 상태 옵션

View File

@@ -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: '구조검토 일괄 삭제에 실패했습니다.' };
}
}

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

@@ -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(() => {

View File

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

View File

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

View File

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

View File

@@ -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: '서버 오류가 발생했습니다.' };
}
}

View File

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

View File

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

View File

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

View File

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

View File

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