From 8c8d76b6c34fa12bc76165a1774faf5a671e20c6 Mon Sep 17 00:00:00 2001 From: hskwon Date: Mon, 19 Jan 2026 20:23:42 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EA=B2=AC=EC=A0=81=20=E2=86=92=20?= =?UTF-8?q?=EC=9E=85=EC=B0=B0=20=EC=A0=84=ED=99=98=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 견적 상세에서 입찰 등록 버튼 및 확인 다이얼로그 추가 - createBiddingFromEstimate 액션 연동 - 입찰 등록 후 리다이렉트 URL 수정 (/biddings → /bidding) - getUserOptions API 경로 수정 (/users → /users/index) - per_page 파라미터명 수정 (size → per_page) --- .../business/construction/bidding/actions.ts | 752 +++++++----------- .../estimates/EstimateDetailForm.tsx | 411 +++++++--- .../construction/estimates/actions.ts | 4 +- 3 files changed, 604 insertions(+), 563 deletions(-) diff --git a/src/components/business/construction/bidding/actions.ts b/src/components/business/construction/bidding/actions.ts index caeedf8c..b137d689 100644 --- a/src/components/business/construction/bidding/actions.ts +++ b/src/components/business/construction/bidding/actions.ts @@ -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 = {}; - 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 = { + 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 = {}; - 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 = { + 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); } \ No newline at end of file diff --git a/src/components/business/construction/estimates/EstimateDetailForm.tsx b/src/components/business/construction/estimates/EstimateDetailForm.tsx index 55be9107..a90ec122 100644 --- a/src/components/business/construction/estimates/EstimateDetailForm.tsx +++ b/src/components/business/construction/estimates/EstimateDetailForm.tsx @@ -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 ( +
+ + + + +
+ ); + } return ( - <> - - - + + ); - }, [isViewMode]); - - // 폼 내용 렌더링 함수 - const renderFormContent = () => ( -
- {/* 견적 정보 + 현장설명회 + 입찰 정보 */} - setFormData((prev) => ({ ...prev, ...updates }))} - onBidInfoChange={handleBidInfoChange} - onDocumentUpload={handleDocumentUpload} - onDocumentRemove={handleDocumentRemove} - onDragOver={handleDragOver} - onDragLeave={handleDragLeave} - onDrop={handleDrop} - /> - - {/* 견적 요약 정보 */} - - - {/* 공과 상세 */} - ({ value: opt.value, label: opt.label }))} - isViewMode={isViewMode} - onAddItems={handleAddExpenseItems} - onRemoveSelected={handleRemoveSelectedExpenseItems} - onItemChange={handleExpenseItemChange} - onSelectItem={handleExpenseSelectItem} - onSelectAll={handleExpenseSelectAll} - /> - - {/* 품목 단가 조정 */} - - - {/* 견적 상세 테이블 */} - -
- ); + }, [isViewMode, isLoading, handleBack, handleEdit, handleDelete, handleSave, handleRegisterBidding]); return ( - <> - renderFormContent()} - renderForm={() => renderFormContent()} + + - {/* 전자결재 모달 (특수 기능) */} +
+ {/* 견적 정보 + 현장설명회 + 입찰 정보 */} + setFormData((prev) => ({ ...prev, ...updates }))} + onBidInfoChange={handleBidInfoChange} + onDocumentUpload={handleDocumentUpload} + onDocumentRemove={handleDocumentRemove} + onDragOver={handleDragOver} + onDragLeave={handleDragLeave} + onDrop={handleDrop} + /> + + {/* 견적 요약 정보 */} + + + {/* 공과 상세 */} + ({ value: opt.value, label: opt.label }))} + isViewMode={isViewMode} + onAddItems={handleAddExpenseItems} + onRemoveSelected={handleRemoveSelectedExpenseItems} + onItemChange={handleExpenseItemChange} + onSelectItem={handleExpenseSelectItem} + onSelectAll={handleExpenseSelectAll} + /> + + {/* 품목 단가 조정 */} + + + {/* 견적 상세 테이블 */} + +
+ + {/* 전자결재 모달 */} setShowApprovalModal(false)} @@ -653,13 +759,86 @@ export default function EstimateDetailForm({ }} /> - {/* 견적서 모달 (특수 기능) */} + {/* 견적서 모달 */} setShowDocumentModal(false)} formData={formData} estimateId={estimateId} /> - + + {/* 삭제 확인 다이얼로그 */} + + + + 견적 삭제 + + 이 견적을 삭제하시겠습니까? +
+ 삭제된 견적은 복구할 수 없습니다. +
+
+ + 취소 + + {isLoading && } + 삭제 + + +
+
+ + {/* 저장 확인 다이얼로그 */} + + + + 수정 확인 + + 견적 정보를 수정하시겠습니까? + + + + 취소 + + {isLoading && } + 확인 + + + + + + {/* 입찰 등록 확인 다이얼로그 */} + + + + 입찰 등록 + + 이 견적을 입찰로 등록하시겠습니까? +
+ 견적 정보가 입찰 관리로 전환됩니다. +
+
+ + 취소 + + {isLoading && } + 등록 + + +
+
+
); } diff --git a/src/components/business/construction/estimates/actions.ts b/src/components/business/construction/estimates/actions.ts index b40897e4..718e1725 100644 --- a/src/components/business/construction/estimates/actions.ts +++ b/src/components/business/construction/estimates/actions.ts @@ -1073,10 +1073,10 @@ export async function getUserOptions(): Promise<{ email?: string; }>; }; - }>('/users', { + }>('/users/index', { params: { active: '1', - size: '100', + per_page: '100', }, });