feat: 견적 → 입찰 전환 기능 구현

- 견적 상세에서 입찰 등록 버튼 및 확인 다이얼로그 추가
- createBiddingFromEstimate 액션 연동
- 입찰 등록 후 리다이렉트 URL 수정 (/biddings → /bidding)
- getUserOptions API 경로 수정 (/users → /users/index)
- per_page 파라미터명 수정 (size → per_page)
This commit is contained in:
2026-01-19 20:23:42 +09:00
parent d12618f320
commit 8c8d76b6c3
3 changed files with 604 additions and 563 deletions

View File

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

View File

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

View File

@@ -1073,10 +1073,10 @@ export async function getUserOptions(): Promise<{
email?: string;
}>;
};
}>('/users', {
}>('/users/index', {
params: {
active: '1',
size: '100',
per_page: '100',
},
});