feat: 견적 → 입찰 전환 기능 구현
- 견적 상세에서 입찰 등록 버튼 및 확인 다이얼로그 추가 - createBiddingFromEstimate 액션 연동 - 입찰 등록 후 리다이렉트 URL 수정 (/biddings → /bidding) - getUserOptions API 경로 수정 (/users → /users/index) - per_page 파라미터명 수정 (size → per_page)
This commit is contained in:
@@ -10,230 +10,92 @@ import type {
|
||||
ExpenseItem,
|
||||
EstimateDetailItem,
|
||||
} from './types';
|
||||
import { apiClient } from '@/lib/api';
|
||||
|
||||
// 목업 데이터
|
||||
const MOCK_BIDDINGS: Bidding[] = [
|
||||
{
|
||||
id: '1',
|
||||
biddingCode: 'BID-2025-001',
|
||||
partnerId: '1',
|
||||
partnerName: '이사대표',
|
||||
projectName: '광장 아파트',
|
||||
biddingDate: '2025-01-25',
|
||||
totalCount: 15,
|
||||
biddingAmount: 71000000,
|
||||
bidDate: '2025-01-20',
|
||||
submissionDate: '2025-01-22',
|
||||
confirmDate: '2025-01-25',
|
||||
status: 'awarded',
|
||||
bidderId: 'hong',
|
||||
bidderName: '홍길동',
|
||||
remarks: '',
|
||||
createdAt: '2025-01-01',
|
||||
updatedAt: '2025-01-01',
|
||||
createdBy: 'system',
|
||||
estimateId: '1',
|
||||
estimateCode: 'EST-2025-001',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
biddingCode: 'BID-2025-002',
|
||||
partnerId: '2',
|
||||
partnerName: '야사건설',
|
||||
projectName: '대림아파트',
|
||||
biddingDate: '2025-01-20',
|
||||
totalCount: 22,
|
||||
biddingAmount: 100000000,
|
||||
bidDate: '2025-01-18',
|
||||
submissionDate: null,
|
||||
confirmDate: null,
|
||||
status: 'waiting',
|
||||
bidderId: 'kim',
|
||||
bidderName: '김철수',
|
||||
remarks: '',
|
||||
createdAt: '2025-01-02',
|
||||
updatedAt: '2025-01-02',
|
||||
createdBy: 'system',
|
||||
estimateId: '2',
|
||||
estimateCode: 'EST-2025-002',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
biddingCode: 'BID-2025-003',
|
||||
partnerId: '3',
|
||||
partnerName: '여의건설',
|
||||
projectName: '현장아파트',
|
||||
biddingDate: '2025-01-18',
|
||||
totalCount: 18,
|
||||
biddingAmount: 85000000,
|
||||
bidDate: '2025-01-15',
|
||||
submissionDate: '2025-01-16',
|
||||
confirmDate: '2025-01-18',
|
||||
status: 'awarded',
|
||||
bidderId: 'hong',
|
||||
bidderName: '홍길동',
|
||||
remarks: '',
|
||||
createdAt: '2025-01-03',
|
||||
updatedAt: '2025-01-03',
|
||||
createdBy: 'system',
|
||||
estimateId: '3',
|
||||
estimateCode: 'EST-2025-003',
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
biddingCode: 'BID-2025-004',
|
||||
partnerId: '1',
|
||||
partnerName: '이사대표',
|
||||
projectName: '송파타워',
|
||||
biddingDate: '2025-01-15',
|
||||
totalCount: 30,
|
||||
biddingAmount: 120000000,
|
||||
bidDate: '2025-01-12',
|
||||
submissionDate: '2025-01-13',
|
||||
confirmDate: '2025-01-15',
|
||||
status: 'failed',
|
||||
bidderId: 'lee',
|
||||
bidderName: '이영희',
|
||||
remarks: '가격 경쟁력 부족',
|
||||
createdAt: '2025-01-04',
|
||||
updatedAt: '2025-01-04',
|
||||
createdBy: 'system',
|
||||
estimateId: '4',
|
||||
estimateCode: 'EST-2025-004',
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
biddingCode: 'BID-2025-005',
|
||||
partnerId: '2',
|
||||
partnerName: '야사건설',
|
||||
projectName: '강남센터',
|
||||
biddingDate: '2025-01-12',
|
||||
totalCount: 25,
|
||||
biddingAmount: 95000000,
|
||||
bidDate: '2025-01-10',
|
||||
submissionDate: '2025-01-11',
|
||||
confirmDate: null,
|
||||
status: 'submitted',
|
||||
bidderId: 'hong',
|
||||
bidderName: '홍길동',
|
||||
remarks: '',
|
||||
createdAt: '2025-01-05',
|
||||
updatedAt: '2025-01-05',
|
||||
createdBy: 'system',
|
||||
estimateId: '5',
|
||||
estimateCode: 'EST-2025-005',
|
||||
},
|
||||
{
|
||||
id: '6',
|
||||
biddingCode: 'BID-2025-006',
|
||||
partnerId: '3',
|
||||
partnerName: '여의건설',
|
||||
projectName: '목동센터',
|
||||
biddingDate: '2025-01-10',
|
||||
totalCount: 12,
|
||||
biddingAmount: 78000000,
|
||||
bidDate: '2025-01-08',
|
||||
submissionDate: '2025-01-09',
|
||||
confirmDate: '2025-01-10',
|
||||
status: 'invalid',
|
||||
bidderId: 'kim',
|
||||
bidderName: '김철수',
|
||||
remarks: '입찰 조건 미충족',
|
||||
createdAt: '2025-01-06',
|
||||
updatedAt: '2025-01-06',
|
||||
createdBy: 'system',
|
||||
estimateId: '6',
|
||||
estimateCode: 'EST-2025-006',
|
||||
},
|
||||
{
|
||||
id: '7',
|
||||
biddingCode: 'BID-2025-007',
|
||||
partnerId: '1',
|
||||
partnerName: '이사대표',
|
||||
projectName: '서초타워',
|
||||
biddingDate: '2025-01-08',
|
||||
totalCount: 35,
|
||||
biddingAmount: 150000000,
|
||||
bidDate: '2025-01-05',
|
||||
submissionDate: null,
|
||||
confirmDate: null,
|
||||
status: 'waiting',
|
||||
bidderId: 'lee',
|
||||
bidderName: '이영희',
|
||||
remarks: '',
|
||||
createdAt: '2025-01-07',
|
||||
updatedAt: '2025-01-07',
|
||||
createdBy: 'system',
|
||||
estimateId: '7',
|
||||
estimateCode: 'EST-2025-007',
|
||||
},
|
||||
{
|
||||
id: '8',
|
||||
biddingCode: 'BID-2025-008',
|
||||
partnerId: '2',
|
||||
partnerName: '야사건설',
|
||||
projectName: '청담프로젝트',
|
||||
biddingDate: '2025-01-05',
|
||||
totalCount: 40,
|
||||
biddingAmount: 200000000,
|
||||
bidDate: '2025-01-03',
|
||||
submissionDate: '2025-01-04',
|
||||
confirmDate: '2025-01-05',
|
||||
status: 'awarded',
|
||||
bidderId: 'hong',
|
||||
bidderName: '홍길동',
|
||||
remarks: '',
|
||||
createdAt: '2025-01-08',
|
||||
updatedAt: '2025-01-08',
|
||||
createdBy: 'system',
|
||||
estimateId: '8',
|
||||
estimateCode: 'EST-2025-008',
|
||||
},
|
||||
{
|
||||
id: '9',
|
||||
biddingCode: 'BID-2025-009',
|
||||
partnerId: '3',
|
||||
partnerName: '여의건설',
|
||||
projectName: '잠실센터',
|
||||
biddingDate: '2025-01-03',
|
||||
totalCount: 20,
|
||||
biddingAmount: 88000000,
|
||||
bidDate: '2025-01-01',
|
||||
submissionDate: null,
|
||||
confirmDate: null,
|
||||
status: 'hold',
|
||||
bidderId: 'kim',
|
||||
bidderName: '김철수',
|
||||
remarks: '검토 대기 중',
|
||||
createdAt: '2025-01-09',
|
||||
updatedAt: '2025-01-09',
|
||||
createdBy: 'system',
|
||||
estimateId: '9',
|
||||
estimateCode: 'EST-2025-009',
|
||||
},
|
||||
{
|
||||
id: '10',
|
||||
biddingCode: 'BID-2025-010',
|
||||
partnerId: '1',
|
||||
partnerName: '이사대표',
|
||||
projectName: '역삼빌딩',
|
||||
biddingDate: '2025-01-01',
|
||||
totalCount: 10,
|
||||
biddingAmount: 65000000,
|
||||
bidDate: '2024-12-28',
|
||||
submissionDate: null,
|
||||
confirmDate: null,
|
||||
status: 'waiting',
|
||||
bidderId: 'lee',
|
||||
bidderName: '이영희',
|
||||
remarks: '',
|
||||
createdAt: '2025-01-10',
|
||||
updatedAt: '2025-01-10',
|
||||
createdBy: 'system',
|
||||
estimateId: '10',
|
||||
estimateCode: 'EST-2025-010',
|
||||
},
|
||||
];
|
||||
/**
|
||||
* 건설 프로젝트 - 입찰관리 Server Actions
|
||||
* biddings API 사용
|
||||
*/
|
||||
|
||||
// ========================================
|
||||
// API 응답 타입 (Biddings API)
|
||||
// ========================================
|
||||
|
||||
interface ApiBidding {
|
||||
id: number;
|
||||
bidding_code: string;
|
||||
quote_id: number | null;
|
||||
client_id: number | null;
|
||||
client_name: string | null;
|
||||
project_name: string | null;
|
||||
bidding_date: string | null;
|
||||
bid_date: string | null;
|
||||
submission_date: string | null;
|
||||
confirm_date: string | null;
|
||||
total_count: number;
|
||||
bidding_amount: number | string;
|
||||
status: string;
|
||||
bidder_id: number | null;
|
||||
bidder_name: string | null;
|
||||
construction_start_date: string | null;
|
||||
construction_end_date: string | null;
|
||||
vat_type: string;
|
||||
remarks: string | null;
|
||||
expense_items: ExpenseItem[] | null;
|
||||
estimate_detail_items: EstimateDetailItem[] | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
created_by: number | null;
|
||||
// 연관 데이터
|
||||
quote?: {
|
||||
id: number;
|
||||
quote_number: string;
|
||||
};
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 변환 함수
|
||||
// ========================================
|
||||
|
||||
function transformBidding(api: ApiBidding): Bidding {
|
||||
return {
|
||||
id: String(api.id),
|
||||
biddingCode: api.bidding_code,
|
||||
partnerId: api.client_id ? String(api.client_id) : '',
|
||||
partnerName: api.client_name || '',
|
||||
projectName: api.project_name || '',
|
||||
biddingDate: api.bidding_date || '',
|
||||
totalCount: api.total_count,
|
||||
biddingAmount: typeof api.bidding_amount === 'string' ? parseFloat(api.bidding_amount) : api.bidding_amount,
|
||||
bidDate: api.bid_date || '',
|
||||
submissionDate: api.submission_date || null,
|
||||
confirmDate: api.confirm_date || null,
|
||||
status: api.status as Bidding['status'],
|
||||
bidderId: api.bidder_id ? String(api.bidder_id) : '',
|
||||
bidderName: api.bidder_name || '',
|
||||
remarks: api.remarks || '',
|
||||
createdAt: api.created_at,
|
||||
updatedAt: api.updated_at,
|
||||
createdBy: api.created_by ? String(api.created_by) : '',
|
||||
estimateId: api.quote_id ? String(api.quote_id) : '',
|
||||
estimateCode: api.quote?.quote_number || '',
|
||||
};
|
||||
}
|
||||
|
||||
function transformBiddingDetail(api: ApiBidding): BiddingDetail {
|
||||
return {
|
||||
...transformBidding(api),
|
||||
constructionStartDate: api.construction_start_date || '',
|
||||
constructionEndDate: api.construction_end_date || '',
|
||||
vatType: (api.vat_type || 'excluded') as 'included' | 'excluded',
|
||||
expenseItems: api.expense_items || [],
|
||||
estimateDetailItems: api.estimate_detail_items || [],
|
||||
};
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// API 함수
|
||||
// ========================================
|
||||
|
||||
// 입찰 목록 조회
|
||||
export async function getBiddingList(filter?: BiddingFilter): Promise<{
|
||||
@@ -241,112 +103,84 @@ export async function getBiddingList(filter?: BiddingFilter): Promise<{
|
||||
data?: BiddingListResponse;
|
||||
error?: string;
|
||||
}> {
|
||||
console.log('🔍 [getBiddingList] 시작, filter:', filter);
|
||||
|
||||
try {
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
const queryParams: Record<string, string> = {};
|
||||
|
||||
let filteredData = [...MOCK_BIDDINGS];
|
||||
// 검색 (API는 'search' 파라미터 사용)
|
||||
if (filter?.search) queryParams.search = filter.search;
|
||||
|
||||
// 검색 필터
|
||||
if (filter?.search) {
|
||||
const search = filter.search.toLowerCase();
|
||||
filteredData = filteredData.filter(
|
||||
(item) =>
|
||||
item.biddingCode.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);
|
||||
queryParams.status = filter.status;
|
||||
}
|
||||
|
||||
// 거래처 필터
|
||||
if (filter?.partnerId && filter.partnerId !== 'all') {
|
||||
filteredData = filteredData.filter((item) => item.partnerId === filter.partnerId);
|
||||
queryParams.client_id = filter.partnerId;
|
||||
}
|
||||
|
||||
// 입찰자 필터
|
||||
if (filter?.bidderId && filter.bidderId !== 'all') {
|
||||
filteredData = filteredData.filter((item) => item.bidderId === filter.bidderId);
|
||||
queryParams.bidder_id = filter.bidderId;
|
||||
}
|
||||
|
||||
// 날짜 필터
|
||||
if (filter?.startDate) {
|
||||
filteredData = filteredData.filter(
|
||||
(item) => item.biddingDate && item.biddingDate >= filter.startDate!
|
||||
);
|
||||
}
|
||||
if (filter?.endDate) {
|
||||
filteredData = filteredData.filter(
|
||||
(item) => item.biddingDate && item.biddingDate <= filter.endDate!
|
||||
);
|
||||
// 날짜 범위 (API는 'from_date', 'to_date' 파라미터 사용)
|
||||
if (filter?.startDate) queryParams.from_date = filter.startDate;
|
||||
if (filter?.endDate) queryParams.to_date = filter.endDate;
|
||||
|
||||
// 정렬 (API는 'sort_dir' 파라미터 사용)
|
||||
if (filter?.sortBy) {
|
||||
const sortMap: Record<string, { sort_by: string; sort_dir: string }> = {
|
||||
biddingDateDesc: { sort_by: 'bidding_date', sort_dir: 'desc' },
|
||||
biddingDateAsc: { sort_by: 'bidding_date', sort_dir: 'asc' },
|
||||
submissionDateDesc: { sort_by: 'submission_date', sort_dir: 'desc' },
|
||||
confirmDateDesc: { sort_by: 'confirm_date', sort_dir: 'desc' },
|
||||
partnerNameAsc: { sort_by: 'client_name', sort_dir: 'asc' },
|
||||
partnerNameDesc: { sort_by: 'client_name', sort_dir: 'desc' },
|
||||
projectNameAsc: { sort_by: 'project_name', sort_dir: 'asc' },
|
||||
projectNameDesc: { sort_by: 'project_name', sort_dir: 'desc' },
|
||||
};
|
||||
const sortConfig = sortMap[filter.sortBy];
|
||||
if (sortConfig) {
|
||||
queryParams.sort_by = sortConfig.sort_by;
|
||||
queryParams.sort_dir = sortConfig.sort_dir;
|
||||
}
|
||||
}
|
||||
|
||||
// 정렬
|
||||
const sortBy = filter?.sortBy || 'biddingDateDesc';
|
||||
switch (sortBy) {
|
||||
case 'biddingDateDesc':
|
||||
filteredData.sort((a, b) => {
|
||||
if (!a.biddingDate) return 1;
|
||||
if (!b.biddingDate) return -1;
|
||||
return new Date(b.biddingDate).getTime() - new Date(a.biddingDate).getTime();
|
||||
});
|
||||
break;
|
||||
case 'biddingDateAsc':
|
||||
filteredData.sort((a, b) => {
|
||||
if (!a.biddingDate) return 1;
|
||||
if (!b.biddingDate) return -1;
|
||||
return new Date(a.biddingDate).getTime() - new Date(b.biddingDate).getTime();
|
||||
});
|
||||
break;
|
||||
case 'submissionDateDesc':
|
||||
filteredData.sort((a, b) => {
|
||||
if (!a.submissionDate) return 1;
|
||||
if (!b.submissionDate) return -1;
|
||||
return new Date(b.submissionDate).getTime() - new Date(a.submissionDate).getTime();
|
||||
});
|
||||
break;
|
||||
case 'confirmDateDesc':
|
||||
filteredData.sort((a, b) => {
|
||||
if (!a.confirmDate) return 1;
|
||||
if (!b.confirmDate) return -1;
|
||||
return new Date(b.confirmDate).getTime() - new Date(a.confirmDate).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;
|
||||
}
|
||||
// 페이지네이션 (API는 'per_page' 파라미터 사용)
|
||||
if (filter?.page) queryParams.page = String(filter.page);
|
||||
if (filter?.size) queryParams.per_page = String(filter.size);
|
||||
|
||||
// 페이지네이션
|
||||
const page = filter?.page || 1;
|
||||
const size = filter?.size || 20;
|
||||
const startIndex = (page - 1) * size;
|
||||
const paginatedData = filteredData.slice(startIndex, startIndex + size);
|
||||
console.log('🔍 [getBiddingList] API 호출: /biddings, params:', queryParams);
|
||||
|
||||
const response = await apiClient.get<{
|
||||
success: boolean;
|
||||
data: {
|
||||
data: ApiBidding[];
|
||||
current_page: number;
|
||||
per_page: number;
|
||||
total: number;
|
||||
last_page: number;
|
||||
};
|
||||
}>('/biddings', { params: queryParams });
|
||||
|
||||
console.log('✅ [getBiddingList] API 응답:', JSON.stringify(response, null, 2).slice(0, 500));
|
||||
|
||||
const paginatedData = response.data;
|
||||
const items = (paginatedData.data || []).map(transformBidding);
|
||||
|
||||
console.log('✅ [getBiddingList] 변환된 items 개수:', items.length);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
items: paginatedData,
|
||||
total: filteredData.length,
|
||||
page,
|
||||
size,
|
||||
totalPages: Math.ceil(filteredData.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('getBiddingList error:', error);
|
||||
console.error('❌ [getBiddingList] 에러:', error);
|
||||
return { success: false, error: '입찰 목록을 불러오는데 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
@@ -358,15 +192,23 @@ export async function getBiddingStats(): Promise<{
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
const response = await apiClient.get<{
|
||||
success: boolean;
|
||||
data: {
|
||||
total: number;
|
||||
waiting: number;
|
||||
awarded: number;
|
||||
};
|
||||
}>('/biddings/stats');
|
||||
|
||||
const stats: BiddingStats = {
|
||||
total: MOCK_BIDDINGS.length,
|
||||
waiting: MOCK_BIDDINGS.filter((b) => b.status === 'waiting').length,
|
||||
awarded: MOCK_BIDDINGS.filter((b) => b.status === 'awarded').length,
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
total: response.data.total,
|
||||
waiting: response.data.waiting,
|
||||
awarded: response.data.awarded,
|
||||
},
|
||||
};
|
||||
|
||||
return { success: true, data: stats };
|
||||
} catch (error) {
|
||||
console.error('getBiddingStats error:', error);
|
||||
return { success: false, error: '통계를 불러오는데 실패했습니다.' };
|
||||
@@ -380,14 +222,8 @@ export async function getBidding(id: string): Promise<{
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
|
||||
const bidding = MOCK_BIDDINGS.find((b) => b.id === id);
|
||||
if (!bidding) {
|
||||
return { success: false, error: '입찰 정보를 찾을 수 없습니다.' };
|
||||
}
|
||||
|
||||
return { success: true, data: bidding };
|
||||
const response = await apiClient.get<{ success: boolean; data: ApiBidding }>(`/biddings/${id}`);
|
||||
return { success: true, data: transformBidding(response.data) };
|
||||
} catch (error) {
|
||||
console.error('getBidding error:', error);
|
||||
return { success: false, error: '입찰 정보를 불러오는데 실패했습니다.' };
|
||||
@@ -400,13 +236,7 @@ export async function deleteBidding(id: string): Promise<{
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
|
||||
const index = MOCK_BIDDINGS.findIndex((b) => b.id === id);
|
||||
if (index === -1) {
|
||||
return { success: false, error: '입찰 정보를 찾을 수 없습니다.' };
|
||||
}
|
||||
|
||||
await apiClient.delete(`/biddings/${id}`);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('deleteBidding error:', error);
|
||||
@@ -421,8 +251,9 @@ export async function deleteBiddings(ids: string[]): Promise<{
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
|
||||
await apiClient.delete('/biddings/bulk', {
|
||||
data: { ids: ids.map((id) => parseInt(id, 10)) },
|
||||
});
|
||||
return { success: true, deletedCount: ids.length };
|
||||
} catch (error) {
|
||||
console.error('deleteBiddings error:', error);
|
||||
@@ -430,92 +261,6 @@ export async function deleteBiddings(ids: string[]): Promise<{
|
||||
}
|
||||
}
|
||||
|
||||
// 공과 상세 목업 데이터
|
||||
const MOCK_EXPENSE_ITEMS: ExpenseItem[] = [
|
||||
{ id: '1', name: '설계비', amount: 5000000 },
|
||||
{ id: '2', name: '운반비', amount: 3000000 },
|
||||
{ id: '3', name: '기타경비', amount: 2000000 },
|
||||
];
|
||||
|
||||
// 견적 상세 목업 데이터
|
||||
const MOCK_ESTIMATE_DETAIL_ITEMS: EstimateDetailItem[] = [
|
||||
{
|
||||
id: '1',
|
||||
no: 1,
|
||||
name: '방화문',
|
||||
material: 'SUS304',
|
||||
width: 1000,
|
||||
height: 2100,
|
||||
quantity: 10,
|
||||
box: 2,
|
||||
coating: 1,
|
||||
batting: 0,
|
||||
mounting: 1,
|
||||
shift: 0,
|
||||
painting: 1,
|
||||
motor: 0,
|
||||
controller: 0,
|
||||
unitPrice: 1500000,
|
||||
expense: 100000,
|
||||
expenseQuantity: 10,
|
||||
totalPrice: 16000000,
|
||||
marginRate: 15,
|
||||
marginCost: 2400000,
|
||||
progressPayment: 8000000,
|
||||
execution: 13600000,
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
no: 2,
|
||||
name: '자동문',
|
||||
material: 'AL',
|
||||
width: 1800,
|
||||
height: 2400,
|
||||
quantity: 5,
|
||||
box: 1,
|
||||
coating: 1,
|
||||
batting: 1,
|
||||
mounting: 1,
|
||||
shift: 1,
|
||||
painting: 0,
|
||||
motor: 1,
|
||||
controller: 1,
|
||||
unitPrice: 3500000,
|
||||
expense: 200000,
|
||||
expenseQuantity: 5,
|
||||
totalPrice: 18500000,
|
||||
marginRate: 18,
|
||||
marginCost: 3330000,
|
||||
progressPayment: 9250000,
|
||||
execution: 15170000,
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
no: 3,
|
||||
name: '셔터',
|
||||
material: 'STEEL',
|
||||
width: 3000,
|
||||
height: 3500,
|
||||
quantity: 3,
|
||||
box: 1,
|
||||
coating: 1,
|
||||
batting: 0,
|
||||
mounting: 1,
|
||||
shift: 0,
|
||||
painting: 1,
|
||||
motor: 1,
|
||||
controller: 1,
|
||||
unitPrice: 8000000,
|
||||
expense: 500000,
|
||||
expenseQuantity: 3,
|
||||
totalPrice: 25500000,
|
||||
marginRate: 20,
|
||||
marginCost: 5100000,
|
||||
progressPayment: 12750000,
|
||||
execution: 20400000,
|
||||
},
|
||||
];
|
||||
|
||||
// 입찰 상세 조회
|
||||
export async function getBiddingDetail(id: string): Promise<{
|
||||
success: boolean;
|
||||
@@ -523,24 +268,8 @@ export async function getBiddingDetail(id: string): Promise<{
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
|
||||
const bidding = MOCK_BIDDINGS.find((b) => b.id === id);
|
||||
if (!bidding) {
|
||||
return { success: false, error: '입찰 정보를 찾을 수 없습니다.' };
|
||||
}
|
||||
|
||||
// 상세 데이터 생성
|
||||
const biddingDetail: BiddingDetail = {
|
||||
...bidding,
|
||||
constructionStartDate: '2025-02-01',
|
||||
constructionEndDate: '2025-04-30',
|
||||
vatType: 'excluded',
|
||||
expenseItems: MOCK_EXPENSE_ITEMS,
|
||||
estimateDetailItems: MOCK_ESTIMATE_DETAIL_ITEMS,
|
||||
};
|
||||
|
||||
return { success: true, data: biddingDetail };
|
||||
const response = await apiClient.get<{ success: boolean; data: ApiBidding }>(`/biddings/${id}`);
|
||||
return { success: true, data: transformBiddingDetail(response.data) };
|
||||
} catch (error) {
|
||||
console.error('getBiddingDetail error:', error);
|
||||
return { success: false, error: '입찰 상세를 불러오는데 실패했습니다.' };
|
||||
@@ -556,19 +285,152 @@ export async function updateBidding(
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
// camelCase → snake_case 변환
|
||||
const payload: Record<string, unknown> = {};
|
||||
|
||||
const index = MOCK_BIDDINGS.findIndex((b) => b.id === id);
|
||||
if (index === -1) {
|
||||
return { success: false, error: '입찰 정보를 찾을 수 없습니다.' };
|
||||
}
|
||||
|
||||
// 목업에서는 실제 업데이트하지 않음
|
||||
console.log('Updating bidding:', id, data);
|
||||
if (data.projectName !== undefined) payload.project_name = data.projectName;
|
||||
if (data.biddingDate !== undefined) payload.bidding_date = data.biddingDate;
|
||||
if (data.submissionDate !== undefined) payload.submission_date = data.submissionDate;
|
||||
if (data.confirmDate !== undefined) payload.confirm_date = data.confirmDate;
|
||||
if (data.totalCount !== undefined) payload.total_count = data.totalCount;
|
||||
if (data.biddingAmount !== undefined) payload.bidding_amount = data.biddingAmount;
|
||||
if (data.status !== undefined) payload.status = data.status;
|
||||
if (data.bidderId !== undefined) payload.bidder_id = data.bidderId ? parseInt(data.bidderId, 10) : null;
|
||||
if (data.bidderName !== undefined) payload.bidder_name = data.bidderName;
|
||||
if (data.constructionStartDate !== undefined) payload.construction_start_date = data.constructionStartDate;
|
||||
if (data.constructionEndDate !== undefined) payload.construction_end_date = data.constructionEndDate;
|
||||
if (data.vatType !== undefined) payload.vat_type = data.vatType;
|
||||
if (data.remarks !== undefined) payload.remarks = data.remarks;
|
||||
|
||||
await apiClient.put(`/biddings/${id}`, payload);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('updateBidding error:', error);
|
||||
return { success: false, error: '입찰 수정에 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// 입찰 상태 변경
|
||||
export async function updateBiddingStatus(
|
||||
id: string,
|
||||
status: Bidding['status']
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
await apiClient.patch(`/biddings/${id}/status`, { status });
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('updateBiddingStatus error:', error);
|
||||
return { success: false, error: '상태 변경에 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 입찰 생성 (견적에서 전환)
|
||||
// ========================================
|
||||
|
||||
// 입찰 생성 요청 데이터
|
||||
export interface CreateBiddingData {
|
||||
quoteId?: number; // 견적 ID (연결)
|
||||
clientId?: number;
|
||||
clientName?: string;
|
||||
projectName: string;
|
||||
biddingDate?: string;
|
||||
bidDate?: string;
|
||||
submissionDate?: string;
|
||||
confirmDate?: string;
|
||||
totalCount?: number;
|
||||
biddingAmount?: number;
|
||||
status?: Bidding['status'];
|
||||
bidderId?: number;
|
||||
bidderName?: string;
|
||||
constructionStartDate?: string;
|
||||
constructionEndDate?: string;
|
||||
vatType?: 'included' | 'excluded';
|
||||
remarks?: string;
|
||||
expenseItems?: ExpenseItem[];
|
||||
estimateDetailItems?: EstimateDetailItem[];
|
||||
}
|
||||
|
||||
// 입찰 생성
|
||||
export async function createBidding(data: CreateBiddingData): Promise<{
|
||||
success: boolean;
|
||||
data?: Bidding;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
// camelCase → snake_case 변환
|
||||
const payload: Record<string, unknown> = {
|
||||
project_name: data.projectName,
|
||||
};
|
||||
|
||||
if (data.quoteId !== undefined) payload.quote_id = data.quoteId;
|
||||
if (data.clientId !== undefined) payload.client_id = data.clientId;
|
||||
if (data.clientName !== undefined) payload.client_name = data.clientName;
|
||||
if (data.biddingDate !== undefined) payload.bidding_date = data.biddingDate;
|
||||
if (data.bidDate !== undefined) payload.bid_date = data.bidDate;
|
||||
if (data.submissionDate !== undefined) payload.submission_date = data.submissionDate;
|
||||
if (data.confirmDate !== undefined) payload.confirm_date = data.confirmDate;
|
||||
if (data.totalCount !== undefined) payload.total_count = data.totalCount;
|
||||
if (data.biddingAmount !== undefined) payload.bidding_amount = data.biddingAmount;
|
||||
if (data.status !== undefined) payload.status = data.status;
|
||||
if (data.bidderId !== undefined) payload.bidder_id = data.bidderId;
|
||||
if (data.bidderName !== undefined) payload.bidder_name = data.bidderName;
|
||||
if (data.constructionStartDate !== undefined) payload.construction_start_date = data.constructionStartDate;
|
||||
if (data.constructionEndDate !== undefined) payload.construction_end_date = data.constructionEndDate;
|
||||
if (data.vatType !== undefined) payload.vat_type = data.vatType;
|
||||
if (data.remarks !== undefined) payload.remarks = data.remarks;
|
||||
if (data.expenseItems !== undefined) payload.expense_items = data.expenseItems;
|
||||
if (data.estimateDetailItems !== undefined) payload.estimate_detail_items = data.estimateDetailItems;
|
||||
|
||||
const response = await apiClient.post<{ success: boolean; data: ApiBidding }>('/biddings', payload);
|
||||
return { success: true, data: transformBidding(response.data) };
|
||||
} catch (error) {
|
||||
console.error('createBidding error:', error);
|
||||
return { success: false, error: '입찰 생성에 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// 견적에서 입찰로 전환 (헬퍼 함수)
|
||||
export async function createBiddingFromEstimate(estimate: {
|
||||
id: string;
|
||||
partnerId?: string;
|
||||
partnerName?: string;
|
||||
projectName: string;
|
||||
estimateAmount?: number;
|
||||
itemCount?: number;
|
||||
bidDate?: string | null;
|
||||
bidInfo?: {
|
||||
projectName?: string;
|
||||
bidDate?: string;
|
||||
siteCount?: number;
|
||||
constructionStartDate?: string;
|
||||
constructionEndDate?: string;
|
||||
vatType?: string;
|
||||
};
|
||||
expenseItems?: ExpenseItem[];
|
||||
detailItems?: EstimateDetailItem[];
|
||||
}): Promise<{
|
||||
success: boolean;
|
||||
data?: Bidding;
|
||||
error?: string;
|
||||
}> {
|
||||
const biddingData: CreateBiddingData = {
|
||||
quoteId: parseInt(estimate.id, 10),
|
||||
clientId: estimate.partnerId ? parseInt(estimate.partnerId, 10) : undefined,
|
||||
clientName: estimate.partnerName,
|
||||
projectName: estimate.bidInfo?.projectName || estimate.projectName,
|
||||
bidDate: estimate.bidInfo?.bidDate || estimate.bidDate || undefined,
|
||||
totalCount: estimate.bidInfo?.siteCount || estimate.itemCount || 0,
|
||||
biddingAmount: estimate.estimateAmount || 0,
|
||||
constructionStartDate: estimate.bidInfo?.constructionStartDate,
|
||||
constructionEndDate: estimate.bidInfo?.constructionEndDate,
|
||||
vatType: (estimate.bidInfo?.vatType as 'included' | 'excluded') || 'excluded',
|
||||
expenseItems: estimate.expenseItems,
|
||||
estimateDetailItems: estimate.detailItems,
|
||||
};
|
||||
|
||||
return createBidding(biddingData);
|
||||
}
|
||||
@@ -2,11 +2,23 @@
|
||||
|
||||
import { useState, useCallback, useMemo, useRef, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { FileText, Loader2, List } from 'lucide-react';
|
||||
import { getExpenseItemOptions, updateEstimate, type ExpenseItemOption } from './actions';
|
||||
import { createBiddingFromEstimate } from '../bidding/actions';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate';
|
||||
import { estimateConfig } from './estimateConfig';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { PageLayout } from '@/components/organisms/PageLayout';
|
||||
import { PageHeader } from '@/components/organisms/PageHeader';
|
||||
import { toast } from 'sonner';
|
||||
import type {
|
||||
EstimateDetail,
|
||||
@@ -50,8 +62,13 @@ export default function EstimateDetailForm({
|
||||
initialData ? estimateDetailToFormData(initialData) : getEmptyEstimateDetailFormData()
|
||||
);
|
||||
|
||||
// 로딩 상태 (미사용, IntegratedDetailTemplate이 관리)
|
||||
const [isLoading] = useState(false);
|
||||
// 로딩 상태
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
// 다이얼로그 상태
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||
const [showSaveDialog, setShowSaveDialog] = useState(false);
|
||||
const [showBiddingDialog, setShowBiddingDialog] = useState(false);
|
||||
|
||||
// 모달 상태
|
||||
const [showApprovalModal, setShowApprovalModal] = useState(false);
|
||||
@@ -92,8 +109,26 @@ export default function EstimateDetailForm({
|
||||
// 조정단가 적용 여부 (전체 적용 버튼 클릭 시에만 true)
|
||||
const useAdjustedPrice = appliedPrices !== null;
|
||||
|
||||
// ===== 저장 핸들러 (IntegratedDetailTemplate용) =====
|
||||
const handleSubmit = useCallback(async (): Promise<{ success: boolean; error?: string }> => {
|
||||
// ===== 네비게이션 핸들러 =====
|
||||
const handleBack = useCallback(() => {
|
||||
router.push('/ko/construction/project/bidding/estimates');
|
||||
}, [router]);
|
||||
|
||||
const handleEdit = useCallback(() => {
|
||||
router.push(`/ko/construction/project/bidding/estimates/${estimateId}/edit`);
|
||||
}, [router, estimateId]);
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
router.push(`/ko/construction/project/bidding/estimates/${estimateId}`);
|
||||
}, [router, estimateId]);
|
||||
|
||||
// ===== 저장/삭제 핸들러 =====
|
||||
const handleSave = useCallback(() => {
|
||||
setShowSaveDialog(true);
|
||||
}, []);
|
||||
|
||||
const handleConfirmSave = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
// 🔍 디버깅: 저장 전 formData 확인 (브라우저 콘솔)
|
||||
console.log('🔍 [handleConfirmSave] formData.detailItems:', formData.detailItems?.length, '개');
|
||||
@@ -109,30 +144,86 @@ export default function EstimateDetailForm({
|
||||
|
||||
if (result.success) {
|
||||
toast.success('수정이 완료되었습니다.');
|
||||
setShowSaveDialog(false);
|
||||
router.push(`/ko/construction/project/bidding/estimates/${estimateId}`);
|
||||
router.refresh();
|
||||
return { success: true };
|
||||
} else {
|
||||
return { success: false, error: result.error || '저장에 실패했습니다.' };
|
||||
toast.error(result.error || '저장에 실패했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
return { success: false, error: error instanceof Error ? error.message : '저장에 실패했습니다.' };
|
||||
toast.error(error instanceof Error ? error.message : '저장에 실패했습니다.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [router, estimateId, formData, currentUser]);
|
||||
|
||||
// ===== 삭제 핸들러 (IntegratedDetailTemplate용) =====
|
||||
const handleDelete = useCallback(async (): Promise<{ success: boolean; error?: string }> => {
|
||||
const handleDelete = useCallback(() => {
|
||||
setShowDeleteDialog(true);
|
||||
}, []);
|
||||
|
||||
const handleConfirmDelete = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
toast.success('견적이 삭제되었습니다.');
|
||||
setShowDeleteDialog(false);
|
||||
router.push('/ko/construction/project/bidding/estimates');
|
||||
router.refresh();
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: error instanceof Error ? error.message : '삭제에 실패했습니다.' };
|
||||
toast.error(error instanceof Error ? error.message : '삭제에 실패했습니다.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [router]);
|
||||
|
||||
// ===== 입찰 등록 핸들러 =====
|
||||
const handleRegisterBidding = useCallback(() => {
|
||||
setShowBiddingDialog(true);
|
||||
}, []);
|
||||
|
||||
const handleConfirmBidding = useCallback(async () => {
|
||||
if (!initialData) {
|
||||
toast.error('견적 데이터가 없습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const result = await createBiddingFromEstimate({
|
||||
id: estimateId,
|
||||
partnerId: initialData.partnerId,
|
||||
partnerName: initialData.partnerName,
|
||||
projectName: initialData.projectName,
|
||||
estimateAmount: formData.estimateAmount,
|
||||
itemCount: initialData.itemCount,
|
||||
bidDate: initialData.bidDate,
|
||||
bidInfo: {
|
||||
projectName: formData.bidInfo.projectName,
|
||||
bidDate: formData.bidInfo.bidDate,
|
||||
siteCount: formData.bidInfo.siteCount,
|
||||
constructionStartDate: formData.bidInfo.constructionStartDate,
|
||||
constructionEndDate: formData.bidInfo.constructionEndDate,
|
||||
vatType: formData.bidInfo.vatType,
|
||||
},
|
||||
expenseItems: formData.expenseItems,
|
||||
detailItems: formData.detailItems,
|
||||
});
|
||||
|
||||
if (result.success && result.data) {
|
||||
toast.success('입찰이 등록되었습니다.');
|
||||
setShowBiddingDialog(false);
|
||||
// 입찰 상세 페이지로 이동
|
||||
router.push(`/ko/construction/project/bidding/${result.data.id}`);
|
||||
} else {
|
||||
toast.error(result.error || '입찰 등록에 실패했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : '입찰 등록에 실패했습니다.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [initialData, estimateId, formData, router]);
|
||||
|
||||
// ===== 입찰 정보 핸들러 =====
|
||||
const handleBidInfoChange = useCallback((field: string, value: string | number) => {
|
||||
setFormData((prev) => ({
|
||||
@@ -531,117 +622,132 @@ export default function EstimateDetailForm({
|
||||
[isViewMode]
|
||||
);
|
||||
|
||||
// ===== 동적 config (모드에 따른 title 변경) =====
|
||||
const dynamicConfig = useMemo(() => {
|
||||
if (isEditMode) {
|
||||
return {
|
||||
...estimateConfig,
|
||||
title: '견적 수정',
|
||||
description: '견적 정보를 수정합니다',
|
||||
};
|
||||
}
|
||||
return estimateConfig;
|
||||
// ===== 타이틀 및 설명 =====
|
||||
const pageTitle = useMemo(() => {
|
||||
return isEditMode ? '견적 수정' : '견적 상세';
|
||||
}, [isEditMode]);
|
||||
|
||||
// ===== View 모드 커스텀 헤더 버튼 (견적서 보기, 전자결재) =====
|
||||
const customHeaderActions = useMemo(() => {
|
||||
if (!isViewMode) return null;
|
||||
const pageDescription = useMemo(() => {
|
||||
return isEditMode ? '견적 정보를 수정합니다' : '견적 정보를 등록하고 관리합니다';
|
||||
}, [isEditMode]);
|
||||
|
||||
// ===== 헤더 버튼 =====
|
||||
const headerActions = useMemo(() => {
|
||||
if (isViewMode) {
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={() => setShowDocumentModal(true)}>
|
||||
견적서 보기
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => setShowApprovalModal(true)}>
|
||||
전자결재
|
||||
</Button>
|
||||
<Button variant="outline" onClick={handleRegisterBidding} className="text-green-600 border-green-200 hover:bg-green-50">
|
||||
입찰 등록
|
||||
</Button>
|
||||
<Button onClick={handleEdit} className="bg-blue-500 hover:bg-blue-600">
|
||||
수정
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<Button variant="outline" onClick={() => setShowDocumentModal(true)}>
|
||||
견적서 보기
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={handleBack}>
|
||||
<List className="h-4 w-4 mr-2" />
|
||||
목록
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => setShowApprovalModal(true)}>
|
||||
전자결재
|
||||
<Button
|
||||
variant="outline"
|
||||
className="text-red-500 border-red-200 hover:bg-red-50"
|
||||
onClick={handleDelete}
|
||||
>
|
||||
삭제
|
||||
</Button>
|
||||
</>
|
||||
<Button onClick={handleSave} className="bg-blue-500 hover:bg-blue-600" disabled={isLoading}>
|
||||
{isLoading && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
|
||||
저장
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}, [isViewMode]);
|
||||
|
||||
// 폼 내용 렌더링 함수
|
||||
const renderFormContent = () => (
|
||||
<div className="space-y-8">
|
||||
{/* 견적 정보 + 현장설명회 + 입찰 정보 */}
|
||||
<EstimateInfoSection
|
||||
formData={formData}
|
||||
isViewMode={isViewMode}
|
||||
isDragging={isDragging}
|
||||
documentInputRef={documentInputRef}
|
||||
onFormDataChange={(updates) => setFormData((prev) => ({ ...prev, ...updates }))}
|
||||
onBidInfoChange={handleBidInfoChange}
|
||||
onDocumentUpload={handleDocumentUpload}
|
||||
onDocumentRemove={handleDocumentRemove}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
/>
|
||||
|
||||
{/* 견적 요약 정보 */}
|
||||
<EstimateSummarySection
|
||||
summaryItems={formData.summaryItems}
|
||||
summaryMemo={formData.summaryMemo}
|
||||
isViewMode={isViewMode}
|
||||
onAddItem={handleAddSummaryItem}
|
||||
onRemoveItem={handleRemoveSummaryItem}
|
||||
onItemChange={handleSummaryItemChange}
|
||||
onMemoChange={handleSummaryMemoChange}
|
||||
/>
|
||||
|
||||
{/* 공과 상세 */}
|
||||
<ExpenseDetailSection
|
||||
expenseItems={formData.expenseItems}
|
||||
expenseOptions={expenseOptions.map((opt) => ({ value: opt.value, label: opt.label }))}
|
||||
isViewMode={isViewMode}
|
||||
onAddItems={handleAddExpenseItems}
|
||||
onRemoveSelected={handleRemoveSelectedExpenseItems}
|
||||
onItemChange={handleExpenseItemChange}
|
||||
onSelectItem={handleExpenseSelectItem}
|
||||
onSelectAll={handleExpenseSelectAll}
|
||||
/>
|
||||
|
||||
{/* 품목 단가 조정 */}
|
||||
<PriceAdjustmentSection
|
||||
priceAdjustmentData={formData.priceAdjustmentData}
|
||||
isViewMode={isViewMode}
|
||||
onPriceChange={handlePriceAdjustmentChange}
|
||||
onSave={handlePriceAdjustmentSave}
|
||||
onApplyAll={handlePriceAdjustmentApplyAll}
|
||||
onReset={handlePriceAdjustmentReset}
|
||||
/>
|
||||
|
||||
{/* 견적 상세 테이블 */}
|
||||
<EstimateDetailTableSection
|
||||
detailItems={formData.detailItems}
|
||||
appliedPrices={appliedPrices}
|
||||
isViewMode={isViewMode}
|
||||
onAddItems={handleAddDetailItems}
|
||||
onRemoveItem={handleRemoveDetailItem}
|
||||
onRemoveSelected={handleRemoveSelectedDetailItems}
|
||||
onItemChange={handleDetailItemChange}
|
||||
onSelectItem={handleDetailSelectItem}
|
||||
onSelectAll={handleDetailSelectAll}
|
||||
onApplyAdjustedPrice={handleApplyAdjustedPriceToSelected}
|
||||
onReset={handleDetailReset}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}, [isViewMode, isLoading, handleBack, handleEdit, handleDelete, handleSave, handleRegisterBidding]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<IntegratedDetailTemplate
|
||||
config={dynamicConfig}
|
||||
mode={mode}
|
||||
initialData={{}}
|
||||
itemId={estimateId}
|
||||
isLoading={false}
|
||||
onSubmit={handleSubmit}
|
||||
onDelete={estimateId && isViewMode ? handleDelete : undefined}
|
||||
headerActions={customHeaderActions}
|
||||
renderView={() => renderFormContent()}
|
||||
renderForm={() => renderFormContent()}
|
||||
<PageLayout>
|
||||
<PageHeader
|
||||
title={pageTitle}
|
||||
description={pageDescription}
|
||||
icon={FileText}
|
||||
actions={headerActions}
|
||||
onBack={handleBack}
|
||||
/>
|
||||
|
||||
{/* 전자결재 모달 (특수 기능) */}
|
||||
<div className="space-y-8">
|
||||
{/* 견적 정보 + 현장설명회 + 입찰 정보 */}
|
||||
<EstimateInfoSection
|
||||
formData={formData}
|
||||
isViewMode={isViewMode}
|
||||
isDragging={isDragging}
|
||||
documentInputRef={documentInputRef}
|
||||
onFormDataChange={(updates) => setFormData((prev) => ({ ...prev, ...updates }))}
|
||||
onBidInfoChange={handleBidInfoChange}
|
||||
onDocumentUpload={handleDocumentUpload}
|
||||
onDocumentRemove={handleDocumentRemove}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
/>
|
||||
|
||||
{/* 견적 요약 정보 */}
|
||||
<EstimateSummarySection
|
||||
summaryItems={formData.summaryItems}
|
||||
summaryMemo={formData.summaryMemo}
|
||||
isViewMode={isViewMode}
|
||||
onAddItem={handleAddSummaryItem}
|
||||
onRemoveItem={handleRemoveSummaryItem}
|
||||
onItemChange={handleSummaryItemChange}
|
||||
onMemoChange={handleSummaryMemoChange}
|
||||
/>
|
||||
|
||||
{/* 공과 상세 */}
|
||||
<ExpenseDetailSection
|
||||
expenseItems={formData.expenseItems}
|
||||
expenseOptions={expenseOptions.map((opt) => ({ value: opt.value, label: opt.label }))}
|
||||
isViewMode={isViewMode}
|
||||
onAddItems={handleAddExpenseItems}
|
||||
onRemoveSelected={handleRemoveSelectedExpenseItems}
|
||||
onItemChange={handleExpenseItemChange}
|
||||
onSelectItem={handleExpenseSelectItem}
|
||||
onSelectAll={handleExpenseSelectAll}
|
||||
/>
|
||||
|
||||
{/* 품목 단가 조정 */}
|
||||
<PriceAdjustmentSection
|
||||
priceAdjustmentData={formData.priceAdjustmentData}
|
||||
isViewMode={isViewMode}
|
||||
onPriceChange={handlePriceAdjustmentChange}
|
||||
onSave={handlePriceAdjustmentSave}
|
||||
onApplyAll={handlePriceAdjustmentApplyAll}
|
||||
onReset={handlePriceAdjustmentReset}
|
||||
/>
|
||||
|
||||
{/* 견적 상세 테이블 */}
|
||||
<EstimateDetailTableSection
|
||||
detailItems={formData.detailItems}
|
||||
appliedPrices={appliedPrices}
|
||||
isViewMode={isViewMode}
|
||||
onAddItems={handleAddDetailItems}
|
||||
onRemoveItem={handleRemoveDetailItem}
|
||||
onRemoveSelected={handleRemoveSelectedDetailItems}
|
||||
onItemChange={handleDetailItemChange}
|
||||
onSelectItem={handleDetailSelectItem}
|
||||
onSelectAll={handleDetailSelectAll}
|
||||
onApplyAdjustedPrice={handleApplyAdjustedPriceToSelected}
|
||||
onReset={handleDetailReset}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 전자결재 모달 */}
|
||||
<ElectronicApprovalModal
|
||||
isOpen={showApprovalModal}
|
||||
onClose={() => setShowApprovalModal(false)}
|
||||
@@ -653,13 +759,86 @@ export default function EstimateDetailForm({
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 견적서 모달 (특수 기능) */}
|
||||
{/* 견적서 모달 */}
|
||||
<EstimateDocumentModal
|
||||
isOpen={showDocumentModal}
|
||||
onClose={() => setShowDocumentModal(false)}
|
||||
formData={formData}
|
||||
estimateId={estimateId}
|
||||
/>
|
||||
</>
|
||||
|
||||
{/* 삭제 확인 다이얼로그 */}
|
||||
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>견적 삭제</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
이 견적을 삭제하시겠습니까?
|
||||
<br />
|
||||
삭제된 견적은 복구할 수 없습니다.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isLoading}>취소</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleConfirmDelete}
|
||||
className="bg-red-600 hover:bg-red-700"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
|
||||
삭제
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{/* 저장 확인 다이얼로그 */}
|
||||
<AlertDialog open={showSaveDialog} onOpenChange={setShowSaveDialog}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>수정 확인</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
견적 정보를 수정하시겠습니까?
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isLoading}>취소</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleConfirmSave}
|
||||
className="bg-blue-500 hover:bg-blue-600"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
|
||||
확인
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{/* 입찰 등록 확인 다이얼로그 */}
|
||||
<AlertDialog open={showBiddingDialog} onOpenChange={setShowBiddingDialog}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>입찰 등록</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
이 견적을 입찰로 등록하시겠습니까?
|
||||
<br />
|
||||
견적 정보가 입찰 관리로 전환됩니다.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isLoading}>취소</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleConfirmBidding}
|
||||
className="bg-green-600 hover:bg-green-700"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
|
||||
등록
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1073,10 +1073,10 @@ export async function getUserOptions(): Promise<{
|
||||
email?: string;
|
||||
}>;
|
||||
};
|
||||
}>('/users', {
|
||||
}>('/users/index', {
|
||||
params: {
|
||||
active: '1',
|
||||
size: '100',
|
||||
per_page: '100',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user