feat(construction): Phase L 건설관리 3개 모듈 Mock → API 연동
- pricing-management: Mock → apiClient 표준화, types.ts 타입 추가 - estimates: Mock → apiClient 표준화 (복잡한 중첩 타입 처리) - category-management: Mock → apiClient 표준화 (에러 타입 처리)
This commit is contained in:
@@ -1,5 +1,46 @@
|
|||||||
# SAM React 작업 현황
|
# SAM React 작업 현황
|
||||||
|
|
||||||
|
## 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 표준화
|
## 2026-01-09 (목) - Phase 1.3-1.5 건설관리 apiClient 표준화
|
||||||
|
|
||||||
### 작업 목표
|
### 작업 목표
|
||||||
@@ -58,7 +99,7 @@
|
|||||||
✅ Next.js 빌드 성공 (349 페이지)
|
✅ Next.js 빌드 성공 (349 페이지)
|
||||||
|
|
||||||
### Git 커밋
|
### Git 커밋
|
||||||
- 대기 중
|
- React: `5db6e59` refactor(construction): 건설관리 3개 모듈 apiClient 표준화
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -776,16 +817,29 @@ useEffect(() => {
|
|||||||
#### Phase K (✅ 완료) - 보고서
|
#### Phase K (✅ 완료) - 보고서
|
||||||
- [x] K-1 종합분석 (Reports) API 연동 ✅
|
- [x] K-1 종합분석 (Reports) API 연동 ✅
|
||||||
|
|
||||||
#### Phase L (🔄 진행중 ~30%) - 건설관리
|
#### Phase L (🔄 진행중 ~80%) - 건설관리
|
||||||
- [ ] bidding, category-management, contract, estimates (Mock 사용 중)
|
**✅ apiClient 표준화 완료:**
|
||||||
- [ ] handover-report, pricing-management, site-briefings, structure-review (Mock 사용 중)
|
- [x] handover-report (b7b8b90)
|
||||||
- [x] labor-management, order-management, partners, site-management (Custom fetch → 표준화 필요)
|
- [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
|
||||||
|
|
||||||
> **마이그레이션 진행률**: 95% 완료 (37/40 모듈)
|
**⏳ Mock → API 변환 필요 (Backend API 개발 필요):**
|
||||||
|
- [ ] bidding - 입찰관리
|
||||||
|
- [ ] site-briefings - 현장설명회
|
||||||
|
- [ ] structure-review - 구조검토
|
||||||
|
- [ ] labor-management - 노무관리
|
||||||
|
|
||||||
|
> **마이그레이션 진행률**: 97% 완료 (41/43 모듈) - 건설관리 4개 모듈 Backend API 개발 필요
|
||||||
> **점검일**: 2026-01-09
|
> **점검일**: 2026-01-09
|
||||||
|
|
||||||
### 다음 작업
|
### 다음 작업
|
||||||
- Phase L 건설관리 모듈 마이그레이션 완료
|
- Phase L 건설관리 모듈 마이그레이션 완료 (Backend API 개발 필요: bidding, site-briefings, structure-review, labor-management)
|
||||||
- ~~TODO-1: 결재선/참조 Select 변경 불가 문제~~ ✅ 2026-01-09 수정 완료
|
- ~~TODO-1: 결재선/참조 Select 변경 불가 문제~~ ✅ 2026-01-09 수정 완료
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -1,35 +1,79 @@
|
|||||||
'use server';
|
'use server';
|
||||||
|
|
||||||
import type { Category } from './types';
|
import type { Category } from './types';
|
||||||
|
import { apiClient } from '@/lib/api';
|
||||||
|
|
||||||
// ===== 목데이터 (추후 API 연동 시 교체) =====
|
/**
|
||||||
let mockCategories: Category[] = [
|
* 주일 기업 - 카테고리 관리 Server Actions
|
||||||
{ id: '1', name: '슬라이드 OPEN 사이즈', order: 1, isDefault: true },
|
* 표준화된 apiClient 사용 버전
|
||||||
{ id: '2', name: '모터', order: 2, isDefault: true },
|
*/
|
||||||
{ id: '3', name: '공정자재', order: 3, isDefault: true },
|
|
||||||
{ id: '4', name: '철물', order: 4, isDefault: true },
|
|
||||||
];
|
|
||||||
|
|
||||||
// 다음 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<{
|
export async function getCategories(): Promise<{
|
||||||
success: boolean;
|
success: boolean;
|
||||||
data?: Category[];
|
data?: Category[];
|
||||||
error?: string;
|
error?: string;
|
||||||
}> {
|
}> {
|
||||||
try {
|
try {
|
||||||
// 목데이터 반환 (순서대로 정렬)
|
const response = await apiClient.get<{
|
||||||
const sortedCategories = [...mockCategories].sort((a, b) => a.order - b.order);
|
data: ApiCategory[];
|
||||||
return { success: true, data: sortedCategories };
|
}>('/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) {
|
} catch (error) {
|
||||||
console.error('[getCategories] Error:', error);
|
console.error('카테고리 목록 조회 오류:', error);
|
||||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
return { success: false, error: '카테고리 목록을 불러오는데 실패했습니다.' };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== 카테고리 생성 =====
|
/**
|
||||||
|
* 카테고리 생성
|
||||||
|
* POST /api/v1/categories
|
||||||
|
*/
|
||||||
export async function createCategory(data: {
|
export async function createCategory(data: {
|
||||||
name: string;
|
name: string;
|
||||||
}): Promise<{
|
}): Promise<{
|
||||||
@@ -38,22 +82,20 @@ export async function createCategory(data: {
|
|||||||
error?: string;
|
error?: string;
|
||||||
}> {
|
}> {
|
||||||
try {
|
try {
|
||||||
const newCategory: Category = {
|
const response = await apiClient.post<ApiCategory>('/categories', {
|
||||||
id: String(nextId++),
|
|
||||||
name: data.name,
|
name: data.name,
|
||||||
order: mockCategories.length + 1,
|
});
|
||||||
isDefault: false,
|
return { success: true, data: transformCategory(response) };
|
||||||
};
|
|
||||||
|
|
||||||
mockCategories.push(newCategory);
|
|
||||||
return { success: true, data: newCategory };
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[createCategory] Error:', error);
|
console.error('카테고리 생성 오류:', error);
|
||||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
return { success: false, error: '카테고리 생성에 실패했습니다.' };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== 카테고리 수정 =====
|
/**
|
||||||
|
* 카테고리 수정
|
||||||
|
* PUT /api/v1/categories/{id}
|
||||||
|
*/
|
||||||
export async function updateCategory(
|
export async function updateCategory(
|
||||||
id: string,
|
id: string,
|
||||||
data: { name?: string }
|
data: { name?: string }
|
||||||
@@ -63,65 +105,64 @@ export async function updateCategory(
|
|||||||
error?: string;
|
error?: string;
|
||||||
}> {
|
}> {
|
||||||
try {
|
try {
|
||||||
const index = mockCategories.findIndex(c => c.id === id);
|
const response = await apiClient.put<ApiCategory>(`/categories/${id}`, {
|
||||||
if (index === -1) {
|
name: data.name,
|
||||||
return { success: false, error: '카테고리를 찾을 수 없습니다.' };
|
});
|
||||||
}
|
return { success: true, data: transformCategory(response) };
|
||||||
|
|
||||||
mockCategories[index] = {
|
|
||||||
...mockCategories[index],
|
|
||||||
...data,
|
|
||||||
};
|
|
||||||
|
|
||||||
return { success: true, data: mockCategories[index] };
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[updateCategory] Error:', error);
|
console.error('카테고리 수정 오류:', error);
|
||||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
return { success: false, error: '카테고리 수정에 실패했습니다.' };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== 카테고리 삭제 =====
|
/**
|
||||||
|
* 카테고리 삭제
|
||||||
|
* DELETE /api/v1/categories/{id}
|
||||||
|
*/
|
||||||
export async function deleteCategory(id: string): Promise<{
|
export async function deleteCategory(id: string): Promise<{
|
||||||
success: boolean;
|
success: boolean;
|
||||||
error?: string;
|
error?: string;
|
||||||
errorType?: 'IN_USE' | 'DEFAULT' | 'GENERAL';
|
errorType?: 'IN_USE' | 'DEFAULT' | 'GENERAL';
|
||||||
}> {
|
}> {
|
||||||
try {
|
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) {
|
// API 에러 응답에서 errorType 추출
|
||||||
return { success: false, error: '카테고리를 찾을 수 없습니다.', errorType: 'GENERAL' };
|
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 (errorType === 'IN_USE') {
|
||||||
if (category.isDefault) {
|
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: '기본 카테고리는 삭제가 불가합니다.',
|
error: errorMessage || '해당 카테고리를 사용하고 있는 품목이 있습니다.',
|
||||||
errorType: 'DEFAULT'
|
errorType: 'IN_USE',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: 품목 사용 여부 체크 로직 (추후 API 연동 시)
|
if (errorType === 'DEFAULT') {
|
||||||
// 현재는 목데이터이므로 사용 중인 품목이 없다고 가정
|
return {
|
||||||
// const itemsUsingCategory = await checkItemsUsingCategory(id);
|
success: false,
|
||||||
// if (itemsUsingCategory.length > 0) {
|
error: errorMessage || '기본 카테고리는 삭제가 불가합니다.',
|
||||||
// return {
|
errorType: 'DEFAULT',
|
||||||
// success: false,
|
};
|
||||||
// error: `"${category.name}"을(를) 사용하고 있는 품목이 있습니다. 모두 변경 후 삭제가 가능합니다.`,
|
}
|
||||||
// errorType: 'IN_USE'
|
|
||||||
// };
|
|
||||||
// }
|
|
||||||
|
|
||||||
mockCategories = mockCategories.filter(c => c.id !== id);
|
return {
|
||||||
return { success: true };
|
success: false,
|
||||||
} catch (error) {
|
error: '카테고리 삭제에 실패했습니다.',
|
||||||
console.error('[deleteCategory] Error:', error);
|
errorType: 'GENERAL',
|
||||||
return { success: false, error: '서버 오류가 발생했습니다.', errorType: 'GENERAL' };
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== 카테고리 순서 변경 =====
|
/**
|
||||||
|
* 카테고리 순서 변경
|
||||||
|
* PUT /api/v1/categories/reorder
|
||||||
|
*/
|
||||||
export async function reorderCategories(
|
export async function reorderCategories(
|
||||||
items: { id: string; sort_order: number }[]
|
items: { id: string; sort_order: number }[]
|
||||||
): Promise<{
|
): Promise<{
|
||||||
@@ -129,17 +170,15 @@ export async function reorderCategories(
|
|||||||
error?: string;
|
error?: string;
|
||||||
}> {
|
}> {
|
||||||
try {
|
try {
|
||||||
// 순서 업데이트
|
await apiClient.put('/categories/reorder', {
|
||||||
items.forEach(item => {
|
items: items.map((item) => ({
|
||||||
const category = mockCategories.find(c => c.id === item.id);
|
id: Number(item.id),
|
||||||
if (category) {
|
sort_order: item.sort_order,
|
||||||
category.order = item.sort_order;
|
})),
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[reorderCategories] Error:', error);
|
console.error('카테고리 순서 변경 오류:', error);
|
||||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
return { success: false, error: '순서 변경에 실패했습니다.' };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,279 +1,551 @@
|
|||||||
'use server';
|
'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
|
* 주일 기업 - 견적관리 Server Actions
|
||||||
* TODO: 실제 API 연동 시 구현
|
* 표준화된 apiClient 사용 버전
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// 목업 데이터
|
// ========================================
|
||||||
const mockEstimates: Estimate[] = [
|
// API 응답 타입
|
||||||
{
|
// ========================================
|
||||||
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: '김철수',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
// 견적 목록 조회
|
interface ApiEstimate {
|
||||||
export async function getEstimateList(
|
id: number;
|
||||||
filter?: EstimateFilter
|
estimate_code: string;
|
||||||
): Promise<{ success: boolean; data?: EstimateListResponse; error?: string }> {
|
partner_id: number | null;
|
||||||
|
partner_name: string | null;
|
||||||
|
project_name: string;
|
||||||
|
estimator_id: number | null;
|
||||||
|
estimator_name: string | null;
|
||||||
|
item_count: number;
|
||||||
|
estimate_amount: number;
|
||||||
|
completed_date: string | null;
|
||||||
|
bid_date: string | null;
|
||||||
|
status: 'pending' | 'approval_waiting' | 'completed' | 'rejected' | 'hold';
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
created_by: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ApiEstimateStats {
|
||||||
|
total_count: number;
|
||||||
|
pending_count: number;
|
||||||
|
completed_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ApiEstimateDetail extends ApiEstimate {
|
||||||
|
site_briefing?: ApiSiteBriefingInfo;
|
||||||
|
bid_info?: ApiBidInfo;
|
||||||
|
summary_items?: ApiSummaryItem[];
|
||||||
|
expense_items?: ApiExpenseItem[];
|
||||||
|
price_adjustments?: ApiPriceAdjustmentItem[];
|
||||||
|
detail_items?: ApiDetailItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ApiSummaryItem {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
quantity: number;
|
||||||
|
unit: string;
|
||||||
|
material_cost: number;
|
||||||
|
labor_cost: number;
|
||||||
|
total_cost: number;
|
||||||
|
remarks: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ApiExpenseItem {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
amount: number;
|
||||||
|
selected: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ApiPriceAdjustmentItem {
|
||||||
|
id: number;
|
||||||
|
category: string;
|
||||||
|
unit_price: number;
|
||||||
|
coating: number;
|
||||||
|
batting: number;
|
||||||
|
box_reinforce: number;
|
||||||
|
painting: number;
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ApiDetailItem {
|
||||||
|
id: number;
|
||||||
|
no: number;
|
||||||
|
name: string;
|
||||||
|
material: string;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
quantity: number;
|
||||||
|
box: number;
|
||||||
|
assembly: number;
|
||||||
|
coating: number;
|
||||||
|
batting: number;
|
||||||
|
mounting: number;
|
||||||
|
fitting: number;
|
||||||
|
controller: number;
|
||||||
|
width_construction: number;
|
||||||
|
height_construction: number;
|
||||||
|
material_cost: number;
|
||||||
|
labor_cost: number;
|
||||||
|
quantity_price: number;
|
||||||
|
expense_quantity: number;
|
||||||
|
expense_total: number;
|
||||||
|
total_cost: number;
|
||||||
|
other_cost: number;
|
||||||
|
margin_cost: number;
|
||||||
|
total_price: number;
|
||||||
|
unit_price: number;
|
||||||
|
expense: number;
|
||||||
|
margin_rate: number;
|
||||||
|
unit_quantity: number;
|
||||||
|
expense_result: number;
|
||||||
|
margin_actual: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 타입 변환 함수
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API 응답 → Estimate 타입 변환
|
||||||
|
*/
|
||||||
|
function transformEstimate(apiData: ApiEstimate): Estimate {
|
||||||
|
return {
|
||||||
|
id: String(apiData.id),
|
||||||
|
estimateCode: apiData.estimate_code || '',
|
||||||
|
partnerId: apiData.partner_id ? String(apiData.partner_id) : '',
|
||||||
|
partnerName: apiData.partner_name || '',
|
||||||
|
projectName: apiData.project_name || '',
|
||||||
|
estimatorId: apiData.estimator_id ? String(apiData.estimator_id) : '',
|
||||||
|
estimatorName: apiData.estimator_name || '',
|
||||||
|
itemCount: apiData.item_count || 0,
|
||||||
|
estimateAmount: apiData.estimate_amount || 0,
|
||||||
|
completedDate: apiData.completed_date || null,
|
||||||
|
bidDate: apiData.bid_date || null,
|
||||||
|
status: apiData.status || 'pending',
|
||||||
|
createdAt: apiData.created_at || '',
|
||||||
|
updatedAt: apiData.updated_at || '',
|
||||||
|
createdBy: apiData.created_by || '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API 응답 → EstimateDetail 타입 변환
|
||||||
|
*/
|
||||||
|
function transformEstimateDetail(apiData: ApiEstimateDetail): EstimateDetail {
|
||||||
|
const base = transformEstimate(apiData);
|
||||||
|
|
||||||
|
const siteBriefing: SiteBriefingInfo = apiData.site_briefing
|
||||||
|
? {
|
||||||
|
briefingCode: apiData.site_briefing.briefing_code || '',
|
||||||
|
partnerName: apiData.site_briefing.partner_name || '',
|
||||||
|
companyName: apiData.site_briefing.company_name || '',
|
||||||
|
briefingDate: apiData.site_briefing.briefing_date || '',
|
||||||
|
attendee: apiData.site_briefing.attendee || '',
|
||||||
|
}
|
||||||
|
: { briefingCode: '', partnerName: '', companyName: '', briefingDate: '', attendee: '' };
|
||||||
|
|
||||||
|
const bidInfo: BidInfo = apiData.bid_info
|
||||||
|
? {
|
||||||
|
projectName: apiData.bid_info.project_name || '',
|
||||||
|
bidDate: apiData.bid_info.bid_date || '',
|
||||||
|
siteCount: apiData.bid_info.site_count || 0,
|
||||||
|
constructionPeriod: apiData.bid_info.construction_period || '',
|
||||||
|
constructionStartDate: apiData.bid_info.construction_start_date || '',
|
||||||
|
constructionEndDate: apiData.bid_info.construction_end_date || '',
|
||||||
|
vatType: apiData.bid_info.vat_type || 'excluded',
|
||||||
|
workReport: apiData.bid_info.work_report || '',
|
||||||
|
documents: (apiData.bid_info.documents || []).map((d) => ({
|
||||||
|
id: String(d.id),
|
||||||
|
fileName: d.file_name || '',
|
||||||
|
fileUrl: d.file_url || '',
|
||||||
|
fileSize: d.file_size || 0,
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
projectName: '',
|
||||||
|
bidDate: '',
|
||||||
|
siteCount: 0,
|
||||||
|
constructionPeriod: '',
|
||||||
|
constructionStartDate: '',
|
||||||
|
constructionEndDate: '',
|
||||||
|
vatType: 'excluded',
|
||||||
|
workReport: '',
|
||||||
|
documents: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const summaryItems: EstimateSummaryItem[] = (apiData.summary_items || []).map((item) => ({
|
||||||
|
id: String(item.id),
|
||||||
|
name: item.name || '',
|
||||||
|
quantity: item.quantity || 0,
|
||||||
|
unit: item.unit || '',
|
||||||
|
materialCost: item.material_cost || 0,
|
||||||
|
laborCost: item.labor_cost || 0,
|
||||||
|
totalCost: item.total_cost || 0,
|
||||||
|
remarks: item.remarks || '',
|
||||||
|
}));
|
||||||
|
|
||||||
|
const expenseItems: ExpenseItem[] = (apiData.expense_items || []).map((item) => ({
|
||||||
|
id: String(item.id),
|
||||||
|
name: item.name || '',
|
||||||
|
amount: item.amount || 0,
|
||||||
|
selected: item.selected || false,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const priceAdjustments: PriceAdjustmentItem[] = (apiData.price_adjustments || []).map((item) => ({
|
||||||
|
id: String(item.id),
|
||||||
|
category: item.category || '',
|
||||||
|
unitPrice: item.unit_price || 0,
|
||||||
|
coating: item.coating || 0,
|
||||||
|
batting: item.batting || 0,
|
||||||
|
boxReinforce: item.box_reinforce || 0,
|
||||||
|
painting: item.painting || 0,
|
||||||
|
total: item.total || 0,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const detailItems: EstimateDetailItem[] = (apiData.detail_items || []).map((item) => ({
|
||||||
|
id: String(item.id),
|
||||||
|
no: item.no || 0,
|
||||||
|
name: item.name || '',
|
||||||
|
material: item.material || '',
|
||||||
|
width: item.width || 0,
|
||||||
|
height: item.height || 0,
|
||||||
|
quantity: item.quantity || 0,
|
||||||
|
box: item.box || 0,
|
||||||
|
assembly: item.assembly || 0,
|
||||||
|
coating: item.coating || 0,
|
||||||
|
batting: item.batting || 0,
|
||||||
|
mounting: item.mounting || 0,
|
||||||
|
fitting: item.fitting || 0,
|
||||||
|
controller: item.controller || 0,
|
||||||
|
widthConstruction: item.width_construction || 0,
|
||||||
|
heightConstruction: item.height_construction || 0,
|
||||||
|
materialCost: item.material_cost || 0,
|
||||||
|
laborCost: item.labor_cost || 0,
|
||||||
|
quantityPrice: item.quantity_price || 0,
|
||||||
|
expenseQuantity: item.expense_quantity || 0,
|
||||||
|
expenseTotal: item.expense_total || 0,
|
||||||
|
totalCost: item.total_cost || 0,
|
||||||
|
otherCost: item.other_cost || 0,
|
||||||
|
marginCost: item.margin_cost || 0,
|
||||||
|
totalPrice: item.total_price || 0,
|
||||||
|
unitPrice: item.unit_price || 0,
|
||||||
|
expense: item.expense || 0,
|
||||||
|
marginRate: item.margin_rate || 0,
|
||||||
|
unitQuantity: item.unit_quantity || 0,
|
||||||
|
expenseResult: item.expense_result || 0,
|
||||||
|
marginActual: item.margin_actual || 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.estimate_code = data.estimateCode;
|
||||||
|
if (data.estimatorId !== undefined) apiData.estimator_id = data.estimatorId || null;
|
||||||
|
if (data.estimatorName !== undefined) apiData.estimator_name = data.estimatorName || null;
|
||||||
|
if (data.estimateAmount !== undefined) apiData.estimate_amount = data.estimateAmount;
|
||||||
|
if (data.status !== undefined) apiData.status = data.status;
|
||||||
|
|
||||||
|
if (data.siteBriefing !== undefined) {
|
||||||
|
apiData.site_briefing = {
|
||||||
|
briefing_code: data.siteBriefing.briefingCode,
|
||||||
|
partner_name: data.siteBriefing.partnerName,
|
||||||
|
company_name: data.siteBriefing.companyName,
|
||||||
|
briefing_date: data.siteBriefing.briefingDate,
|
||||||
|
attendee: data.siteBriefing.attendee,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.bidInfo !== undefined) {
|
||||||
|
apiData.bid_info = {
|
||||||
|
project_name: data.bidInfo.projectName,
|
||||||
|
bid_date: data.bidInfo.bidDate,
|
||||||
|
site_count: data.bidInfo.siteCount,
|
||||||
|
construction_period: data.bidInfo.constructionPeriod,
|
||||||
|
construction_start_date: data.bidInfo.constructionStartDate,
|
||||||
|
construction_end_date: data.bidInfo.constructionEndDate,
|
||||||
|
vat_type: data.bidInfo.vatType,
|
||||||
|
work_report: data.bidInfo.workReport,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return apiData;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// API 함수
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 견적 목록 조회
|
||||||
|
* GET /api/v1/estimates
|
||||||
|
*/
|
||||||
|
export async function getEstimateList(filter?: EstimateFilter): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
data?: EstimateListResponse;
|
||||||
|
error?: string;
|
||||||
|
}> {
|
||||||
try {
|
try {
|
||||||
let filtered = [...mockEstimates];
|
const queryParams: Record<string, string> = {};
|
||||||
|
|
||||||
// 검색 필터
|
// 검색
|
||||||
if (filter?.search) {
|
if (filter?.search) queryParams.search = 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?.status && filter.status !== 'all') {
|
if (filter?.status && filter.status !== 'all') queryParams.status = filter.status;
|
||||||
filtered = filtered.filter((e) => e.status === filter.status);
|
if (filter?.partnerId) queryParams.partner_id = filter.partnerId;
|
||||||
}
|
if (filter?.estimatorId) queryParams.estimator_id = filter.estimatorId;
|
||||||
|
|
||||||
// 거래처 필터
|
// 날짜 범위
|
||||||
if (filter?.partnerId) {
|
if (filter?.startDate) queryParams.start_date = filter.startDate;
|
||||||
filtered = filtered.filter((e) => e.partnerId === filter.partnerId);
|
if (filter?.endDate) queryParams.end_date = filter.endDate;
|
||||||
}
|
|
||||||
|
|
||||||
// 견적자 필터
|
// 페이지네이션
|
||||||
if (filter?.estimatorId) {
|
if (filter?.page) queryParams.page = String(filter.page);
|
||||||
filtered = filtered.filter((e) => e.estimatorId === filter.estimatorId);
|
if (filter?.size) queryParams.per_page = String(filter.size);
|
||||||
}
|
|
||||||
|
|
||||||
// 날짜 필터
|
|
||||||
if (filter?.startDate) {
|
|
||||||
filtered = filtered.filter((e) => e.createdAt >= filter.startDate!);
|
|
||||||
}
|
|
||||||
if (filter?.endDate) {
|
|
||||||
filtered = filtered.filter((e) => e.createdAt <= filter.endDate!);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 정렬
|
// 정렬
|
||||||
if (filter?.sortBy) {
|
if (filter?.sortBy) {
|
||||||
switch (filter.sortBy) {
|
const sortMap: Record<string, { field: string; dir: string }> = {
|
||||||
case 'latest':
|
latest: { field: 'created_at', dir: 'desc' },
|
||||||
filtered.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
oldest: { field: 'created_at', dir: 'asc' },
|
||||||
break;
|
amountDesc: { field: 'estimate_amount', dir: 'desc' },
|
||||||
case 'oldest':
|
amountAsc: { field: 'estimate_amount', dir: 'asc' },
|
||||||
filtered.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
|
bidDateDesc: { field: 'bid_date', dir: 'desc' },
|
||||||
break;
|
partnerNameAsc: { field: 'partner_name', dir: 'asc' },
|
||||||
case 'amountDesc':
|
partnerNameDesc: { field: 'partner_name', dir: 'desc' },
|
||||||
filtered.sort((a, b) => b.estimateAmount - a.estimateAmount);
|
projectNameAsc: { field: 'project_name', dir: 'asc' },
|
||||||
break;
|
projectNameDesc: { field: 'project_name', dir: 'desc' },
|
||||||
case 'amountAsc':
|
};
|
||||||
filtered.sort((a, b) => a.estimateAmount - b.estimateAmount);
|
const sort = sortMap[filter.sortBy];
|
||||||
break;
|
if (sort) {
|
||||||
case 'bidDateDesc':
|
queryParams.sort_by = sort.field;
|
||||||
filtered.sort((a, b) => {
|
queryParams.sort_dir = sort.dir;
|
||||||
if (!a.bidDate) return 1;
|
|
||||||
if (!b.bidDate) return -1;
|
|
||||||
return new Date(b.bidDate).getTime() - new Date(a.bidDate).getTime();
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const page = filter?.page ?? 1;
|
const response = await apiClient.get<{
|
||||||
const size = filter?.size ?? 20;
|
data: ApiEstimate[];
|
||||||
const start = (page - 1) * size;
|
current_page: number;
|
||||||
const paginatedItems = filtered.slice(start, start + size);
|
per_page: number;
|
||||||
|
total: number;
|
||||||
|
last_page: number;
|
||||||
|
}>('/estimates', { params: queryParams });
|
||||||
|
|
||||||
|
const items = (response.data || []).map(transformEstimate);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
data: {
|
data: {
|
||||||
items: paginatedItems,
|
items,
|
||||||
total: filtered.length,
|
total: response.total || 0,
|
||||||
page,
|
page: response.current_page || 1,
|
||||||
size,
|
size: response.per_page || 20,
|
||||||
totalPages: Math.ceil(filtered.length / size),
|
totalPages: response.last_page || 1,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('getEstimateList error:', error);
|
console.error('견적 목록 조회 오류:', error);
|
||||||
return { success: false, error: '견적 목록 조회에 실패했습니다.' };
|
return { success: false, error: '견적 목록을 불러오는데 실패했습니다.' };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 견적 상세 조회
|
/**
|
||||||
export async function getEstimate(
|
* 견적 단건 조회
|
||||||
id: string
|
* GET /api/v1/estimates/{id}
|
||||||
): Promise<{ success: boolean; data?: Estimate; error?: string }> {
|
*/
|
||||||
|
export async function getEstimate(id: string): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
data?: Estimate;
|
||||||
|
error?: string;
|
||||||
|
}> {
|
||||||
try {
|
try {
|
||||||
const estimate = mockEstimates.find((e) => e.id === id);
|
const response = await apiClient.get<ApiEstimate>(`/estimates/${id}`);
|
||||||
|
return { success: true, data: transformEstimate(response) };
|
||||||
if (!estimate) {
|
|
||||||
return { success: false, error: '견적을 찾을 수 없습니다.' };
|
|
||||||
}
|
|
||||||
|
|
||||||
return { success: true, data: estimate };
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('getEstimate error:', error);
|
console.error('견적 조회 오류:', error);
|
||||||
return { success: false, error: '견적 조회에 실패했습니다.' };
|
return { success: false, error: '견적 정보를 찾을 수 없습니다.' };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 견적 통계 조회
|
/**
|
||||||
export async function getEstimateStats(): Promise<{ success: boolean; data?: EstimateStats; error?: string }> {
|
* 견적 상세 조회 (첨부 정보 포함)
|
||||||
|
* GET /api/v1/estimates/{id}/detail
|
||||||
|
*/
|
||||||
|
export async function getEstimateDetail(id: string): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
data?: EstimateDetail;
|
||||||
|
error?: string;
|
||||||
|
}> {
|
||||||
try {
|
try {
|
||||||
const total = mockEstimates.length;
|
const response = await apiClient.get<ApiEstimateDetail>(`/estimates/${id}`);
|
||||||
const pending = mockEstimates.filter((e) => e.status === 'pending').length;
|
return { success: true, data: transformEstimateDetail(response) };
|
||||||
const completed = mockEstimates.filter((e) => e.status === 'completed').length;
|
} catch (error) {
|
||||||
|
console.error('견적 상세 조회 오류:', error);
|
||||||
|
return { success: false, error: '견적 상세 정보를 불러오는데 실패했습니다.' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 견적 통계 조회
|
||||||
|
* GET /api/v1/estimates/stats
|
||||||
|
*/
|
||||||
|
export async function getEstimateStats(): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
data?: EstimateStats;
|
||||||
|
error?: string;
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.get<ApiEstimateStats>('/estimates/stats');
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
data: {
|
data: {
|
||||||
total,
|
total: response.total_count || 0,
|
||||||
pending,
|
pending: response.pending_count || 0,
|
||||||
completed,
|
completed: response.completed_count || 0,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('getEstimateStats error:', error);
|
console.error('견적 통계 조회 오류:', error);
|
||||||
return { success: false, error: '통계 조회에 실패했습니다.' };
|
return { success: false, error: '통계를 불러오는데 실패했습니다.' };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 견적 삭제
|
/**
|
||||||
export async function deleteEstimate(id: string): Promise<{ success: boolean; error?: string }> {
|
* 견적 등록
|
||||||
|
* POST /api/v1/estimates
|
||||||
|
*/
|
||||||
|
export async function createEstimate(data: EstimateDetailFormData): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
data?: Estimate;
|
||||||
|
error?: string;
|
||||||
|
}> {
|
||||||
try {
|
try {
|
||||||
console.log('Delete estimate:', id);
|
const apiData = transformToApiRequest(data);
|
||||||
|
const response = await apiClient.post<ApiEstimate>('/estimates', apiData);
|
||||||
|
return { success: true, data: transformEstimate(response) };
|
||||||
|
} catch (error) {
|
||||||
|
console.error('견적 등록 오류:', error);
|
||||||
|
return { success: false, error: '견적 등록에 실패했습니다.' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 견적 수정
|
||||||
|
* PUT /api/v1/estimates/{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<ApiEstimate>(`/estimates/${id}`, apiData);
|
||||||
|
return { success: true, data: transformEstimate(response) };
|
||||||
|
} catch (error) {
|
||||||
|
console.error('견적 수정 오류:', error);
|
||||||
|
return { success: false, error: '견적 수정에 실패했습니다.' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 견적 삭제
|
||||||
|
* DELETE /api/v1/estimates/{id}
|
||||||
|
*/
|
||||||
|
export async function deleteEstimate(id: string): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
error?: string;
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
await apiClient.delete(`/estimates/${id}`);
|
||||||
return { success: true };
|
return { success: true };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('deleteEstimate error:', error);
|
console.error('견적 삭제 오류:', error);
|
||||||
return { success: false, error: '견적 삭제에 실패했습니다.' };
|
return { success: false, error: '견적 삭제에 실패했습니다.' };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 견적 일괄 삭제
|
/**
|
||||||
export async function deleteEstimates(ids: string[]): Promise<{ success: boolean; deletedCount?: number; error?: string }> {
|
* 견적 일괄 삭제
|
||||||
|
* DELETE /api/v1/estimates/bulk
|
||||||
|
*/
|
||||||
|
export async function deleteEstimates(ids: string[]): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
deletedCount?: number;
|
||||||
|
error?: string;
|
||||||
|
}> {
|
||||||
try {
|
try {
|
||||||
console.log('Delete estimates:', ids);
|
await apiClient.delete('/estimates/bulk', {
|
||||||
|
data: { ids: ids.map((id) => Number(id)) },
|
||||||
|
});
|
||||||
return { success: true, deletedCount: ids.length };
|
return { success: true, deletedCount: ids.length };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('deleteEstimates error:', error);
|
console.error('견적 일괄 삭제 오류:', error);
|
||||||
return { success: false, error: '일괄 삭제에 실패했습니다.' };
|
return { success: false, error: '일괄 삭제에 실패했습니다.' };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,388 +1,378 @@
|
|||||||
'use server';
|
'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[] = [
|
* 주일 기업 - 단가관리 Server Actions
|
||||||
{
|
* 표준화된 apiClient 사용 버전
|
||||||
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',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
// ===== 단가 목록 조회 =====
|
// ========================================
|
||||||
export async function getPricingList(params?: {
|
// API 응답 타입
|
||||||
startDate?: string;
|
// ========================================
|
||||||
endDate?: string;
|
|
||||||
itemType?: string;
|
interface ApiPricing {
|
||||||
category?: string;
|
id: number;
|
||||||
spec?: string;
|
pricing_number: string;
|
||||||
division?: string;
|
item_type: string | null;
|
||||||
status?: string;
|
category: string | null;
|
||||||
sort?: string;
|
item_name: string;
|
||||||
search?: string;
|
spec: string | null;
|
||||||
}): Promise<{
|
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;
|
success: boolean;
|
||||||
data?: { items: Pricing[]; total: number };
|
data?: PricingListResponse;
|
||||||
error?: string;
|
error?: string;
|
||||||
}> {
|
}> {
|
||||||
try {
|
try {
|
||||||
let filtered = [...mockPricingList];
|
const queryParams: Record<string, string> = {};
|
||||||
|
|
||||||
// 품목유형 필터
|
// 검색
|
||||||
if (params?.itemType && params.itemType !== 'all') {
|
if (filter?.search) queryParams.search = filter.search;
|
||||||
const typeMap: Record<string, string> = {
|
|
||||||
box: '박스',
|
|
||||||
parts: '부속',
|
|
||||||
consumables: '소모품',
|
|
||||||
utility: '공과',
|
|
||||||
};
|
|
||||||
filtered = filtered.filter(p => p.itemType === typeMap[params.itemType!]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 카테고리 필터
|
// 필터
|
||||||
if (params?.category && params.category !== 'all') {
|
if (filter?.status && filter.status !== 'all') queryParams.status = filter.status;
|
||||||
const categoryMap: Record<string, string> = {
|
if (filter?.itemType && filter.itemType !== 'all') queryParams.item_type = filter.itemType;
|
||||||
slide_open: '슬라이드 OPEN 사이즈',
|
if (filter?.category && filter.category !== 'all') queryParams.category = filter.category;
|
||||||
motor: '모터',
|
if (filter?.division && filter.division !== 'all') queryParams.division = filter.division;
|
||||||
process_material: '공정자재',
|
|
||||||
hardware: '철물',
|
|
||||||
};
|
|
||||||
filtered = filtered.filter(p => p.category === categoryMap[params.category!]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 구분 필터
|
// 페이지네이션
|
||||||
if (params?.division && params.division !== 'all') {
|
if (filter?.page) queryParams.page = String(filter.page);
|
||||||
const divisionMap: Record<string, string> = {
|
if (filter?.size) queryParams.per_page = String(filter.size);
|
||||||
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 (params?.sort === 'oldest') {
|
if (filter?.sortBy) {
|
||||||
filtered.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
|
const sortMap: Record<string, { field: string; dir: string }> = {
|
||||||
} else if (params?.sort === 'price_high') {
|
latest: { field: 'created_at', dir: 'desc' },
|
||||||
filtered.sort((a, b) => b.sellingPrice - a.sellingPrice);
|
oldest: { field: 'created_at', dir: 'asc' },
|
||||||
} else if (params?.sort === 'price_low') {
|
itemNameAsc: { field: 'item_name', dir: 'asc' },
|
||||||
filtered.sort((a, b) => a.sellingPrice - b.sellingPrice);
|
itemNameDesc: { field: 'item_name', dir: 'desc' },
|
||||||
} else {
|
priceAsc: { field: 'selling_price', dir: 'asc' },
|
||||||
filtered.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
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 {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
data: {
|
data: {
|
||||||
items: filtered,
|
items,
|
||||||
total: filtered.length,
|
total: response.total || 0,
|
||||||
|
page: response.current_page || 1,
|
||||||
|
size: response.per_page || 20,
|
||||||
|
totalPages: response.last_page || 1,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[getPricingList] Error:', error);
|
console.error('단가 목록 조회 오류:', error);
|
||||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
return { success: false, error: '단가 목록을 불러오는데 실패했습니다.' };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== 통계 조회 =====
|
/**
|
||||||
|
* 단가 통계 조회
|
||||||
|
* GET /api/v1/pricing/stats
|
||||||
|
*/
|
||||||
export async function getPricingStats(): Promise<{
|
export async function getPricingStats(): Promise<{
|
||||||
success: boolean;
|
success: boolean;
|
||||||
data?: PricingStats;
|
data?: PricingStats;
|
||||||
error?: string;
|
error?: string;
|
||||||
}> {
|
}> {
|
||||||
try {
|
try {
|
||||||
const stats: PricingStats = {
|
const response = await apiClient.get<ApiPricingStats>('/pricing/stats');
|
||||||
total: mockPricingList.length,
|
|
||||||
inUse: mockPricingList.filter(p => p.status === 'in_use').length,
|
return {
|
||||||
notRegistered: mockPricingList.filter(p => p.status === 'not_registered').length,
|
success: true,
|
||||||
|
data: {
|
||||||
|
total: response.total || 0,
|
||||||
|
inUse: response.in_use || 0,
|
||||||
|
notRegistered: response.not_registered || 0,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
return { success: true, data: stats };
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[getPricingStats] Error:', error);
|
console.error('단가 통계 조회 오류:', error);
|
||||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
return { success: false, error: '통계를 불러오는데 실패했습니다.' };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== 단일 삭제 =====
|
/**
|
||||||
export async function deletePricing(id: string): Promise<{
|
* 단가 상세 조회
|
||||||
success: boolean;
|
* GET /api/v1/pricing/{id}
|
||||||
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: '서버 오류가 발생했습니다.' };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== 단가 상세 조회 =====
|
|
||||||
export async function getPricingDetail(id: string): Promise<{
|
export async function getPricingDetail(id: string): Promise<{
|
||||||
success: boolean;
|
success: boolean;
|
||||||
data?: Pricing;
|
data?: Pricing;
|
||||||
error?: string;
|
error?: string;
|
||||||
}> {
|
}> {
|
||||||
try {
|
try {
|
||||||
const pricing = mockPricingList.find(p => p.id === id);
|
const response = await apiClient.get<ApiPricing>(`/pricing/${id}`);
|
||||||
if (!pricing) {
|
return { success: true, data: transformPricing(response) };
|
||||||
return { success: false, error: '단가를 찾을 수 없습니다.' };
|
|
||||||
}
|
|
||||||
return { success: true, data: pricing };
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[getPricingDetail] Error:', error);
|
console.error('단가 상세 조회 오류:', error);
|
||||||
return { success: false, 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;
|
success: boolean;
|
||||||
data?: Pricing;
|
data?: Pricing;
|
||||||
error?: string;
|
error?: string;
|
||||||
}> {
|
}> {
|
||||||
try {
|
try {
|
||||||
const newId = String(mockPricingList.length + 1);
|
const apiData = transformToApiRequest(data);
|
||||||
const newPricingNumber = `PRC-2026-${String(mockPricingList.length + 1).padStart(3, '0')}`;
|
const response = await apiClient.post<ApiPricing>('/pricing', apiData);
|
||||||
|
return { success: true, data: transformPricing(response) };
|
||||||
const newPricing: Pricing = {
|
|
||||||
...data,
|
|
||||||
id: newId,
|
|
||||||
pricingNumber: newPricingNumber,
|
|
||||||
createdAt: new Date().toISOString().split('T')[0],
|
|
||||||
};
|
|
||||||
|
|
||||||
// 목데이터에는 추가하지 않음 (실제 API 연동 시 DB에 저장)
|
|
||||||
return { success: true, data: newPricing };
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[createPricing] Error:', error);
|
console.error('단가 등록 오류:', error);
|
||||||
return { success: false, 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;
|
success: boolean;
|
||||||
data?: Pricing;
|
data?: Pricing;
|
||||||
error?: string;
|
error?: string;
|
||||||
}> {
|
}> {
|
||||||
try {
|
try {
|
||||||
const index = mockPricingList.findIndex(p => p.id === id);
|
const apiData = transformToApiRequest(data);
|
||||||
if (index === -1) {
|
const response = await apiClient.put<ApiPricing>(`/pricing/${id}`, apiData);
|
||||||
return { success: false, error: '단가를 찾을 수 없습니다.' };
|
return { success: true, data: transformPricing(response) };
|
||||||
}
|
|
||||||
|
|
||||||
const updatedPricing: Pricing = {
|
|
||||||
...mockPricingList[index],
|
|
||||||
...data,
|
|
||||||
updatedAt: new Date().toISOString().split('T')[0],
|
|
||||||
};
|
|
||||||
|
|
||||||
// 목데이터에는 수정하지 않음 (실제 API 연동 시 DB에 업데이트)
|
|
||||||
return { success: true, data: updatedPricing };
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[updatePricing] Error:', error);
|
console.error('단가 수정 오류:', error);
|
||||||
return { success: false, 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<{
|
export async function getVendorList(): Promise<{
|
||||||
success: boolean;
|
success: boolean;
|
||||||
data?: { id: string; name: string }[];
|
data?: { id: string; name: string }[];
|
||||||
error?: string;
|
error?: string;
|
||||||
}> {
|
}> {
|
||||||
try {
|
try {
|
||||||
// 목데이터에서 거래처 추출
|
const response = await apiClient.get<{
|
||||||
const vendors = [
|
data: ApiVendor[];
|
||||||
{ id: '1', name: '(주)슬라이드텍' },
|
}>('/clients', { params: { per_page: '100' } });
|
||||||
{ id: '2', name: '삼성전기' },
|
|
||||||
{ id: '3', name: '현대용접산업' },
|
const vendors = (response.data || []).map((v) => ({
|
||||||
{ id: '4', name: '철강볼트' },
|
id: String(v.id),
|
||||||
{ id: '5', name: '한국윤활유' },
|
name: v.name || '',
|
||||||
];
|
}));
|
||||||
|
|
||||||
return { success: true, data: vendors };
|
return { success: true, data: vendors };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[getVendorList] Error:', error);
|
console.error('거래처 목록 조회 오류:', error);
|
||||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
return { success: false, error: '거래처 목록을 불러오는데 실패했습니다.' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 단가 확정
|
||||||
|
* POST /api/v1/pricing/{id}/finalize
|
||||||
|
*/
|
||||||
|
export async function finalizePricing(id: string): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
data?: Pricing;
|
||||||
|
error?: string;
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.post<ApiPricing>(`/pricing/${id}/finalize`);
|
||||||
|
return { success: true, data: transformPricing(response) };
|
||||||
|
} catch (error) {
|
||||||
|
console.error('단가 확정 오류:', error);
|
||||||
|
return { success: false, error: '단가 확정에 실패했습니다.' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 단가 변경이력 조회
|
||||||
|
* GET /api/v1/pricing/{id}/revisions
|
||||||
|
*/
|
||||||
|
export async function getPricingRevisions(id: string): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
data?: Pricing[];
|
||||||
|
error?: string;
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.get<{ data: ApiPricing[] }>(`/pricing/${id}/revisions`);
|
||||||
|
const revisions = (response.data || []).map(transformPricing);
|
||||||
|
return { success: true, data: revisions };
|
||||||
|
} catch (error) {
|
||||||
|
console.error('단가 변경이력 조회 오류:', error);
|
||||||
|
return { success: false, error: '변경이력을 불러오는데 실패했습니다.' };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -39,6 +39,44 @@ export interface PricingStats {
|
|||||||
notRegistered: number; // 미등록 단가
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
// ===== 필터 옵션 =====
|
// ===== 필터 옵션 =====
|
||||||
|
|
||||||
// 품목유형 옵션
|
// 품목유형 옵션
|
||||||
|
|||||||
Reference in New Issue
Block a user