fix(WEB): 건설 견적관리 API 연동 수정

- actions.ts: /quotes API 호출로 변경 (quote_type=construction 필터)
- actions.ts: API 응답 파싱 수정 (response.data.data 구조 처리)
- EstimateListClient.tsx: size 1000→100 (API 최대값 준수)
This commit is contained in:
2026-01-13 09:55:23 +09:00
parent 6cd5477eed
commit 2b9c70b550
2 changed files with 231 additions and 269 deletions

View File

@@ -104,7 +104,7 @@ export default function EstimateListClient({ initialData = [], initialStats }: E
try {
const [listResult, statsResult] = await Promise.all([
getEstimateList({
size: 1000,
size: 100, // API 최대값 100
startDate: startDate || undefined,
endDate: endDate || undefined,
}),

View File

@@ -17,47 +17,73 @@ import type {
import { apiClient } from '@/lib/api';
/**
* 주일 기업 - 견적관리 Server Actions
* 표준화된 apiClient 사용 버전
* 건설 프로젝트 - 견적관리 Server Actions
* quotes API 사용 (quote_type=construction 필터)
*/
// ========================================
// API 응답 타입
// API 응답 타입 (Quotes API)
// ========================================
interface ApiEstimate {
interface ApiQuote {
id: number;
estimate_code: string;
partner_id: number | null;
partner_name: string | null;
project_name: string;
estimator_id: number | null;
estimator_name: string | null;
item_count: number;
estimate_amount: number;
completed_date: string | null;
bid_date: string | null;
status: 'pending' | 'approval_waiting' | 'completed' | 'rejected' | 'hold';
quote_type: string;
quote_number: string;
registration_date: string;
client_id: number | null;
client_name: string | null;
site_id: number | null;
site_name: string | null;
site_briefing_id: number | null;
product_category: string | null;
product_name: string | null;
total_amount: number | string;
status: string;
author: string | null;
manager: string | null;
remarks: string | null;
created_at: string;
updated_at: string;
created_by: string | null;
created_by: number | null;
// 연관 데이터
items?: ApiQuoteItem[];
site_briefing?: ApiSiteBriefing;
}
interface ApiEstimateStats {
interface ApiQuoteItem {
id: number;
item_code: string;
item_name: string;
specification: string | null;
unit: string;
base_quantity: number;
calculated_quantity: number;
unit_price: number;
total_price: number;
formula: string | null;
note: string | null;
sort_order: number;
}
interface ApiSiteBriefing {
id: number;
briefing_code: string;
title: string;
briefing_date: string;
attendance_status: string;
partner?: {
id: number;
name: string;
};
}
interface ApiQuoteStats {
total_count: number;
pending_count: number;
completed_count: number;
}
interface ApiEstimateDetail extends ApiEstimate {
site_briefing?: ApiSiteBriefingInfo;
bid_info?: ApiBidInfo;
summary_items?: ApiSummaryItem[];
expense_items?: ApiExpenseItem[];
price_adjustments?: ApiPriceAdjustmentItem[];
detail_items?: ApiDetailItem[];
}
// Legacy API types (for backward compatibility)
interface ApiSiteBriefingInfo {
briefing_code: string;
partner_name: string;
@@ -85,202 +111,116 @@ interface ApiBidDocument {
file_size: number;
}
interface ApiSummaryItem {
id: number;
name: string;
quantity: number;
unit: string;
material_cost: number;
labor_cost: number;
total_cost: number;
remarks: string;
}
interface ApiExpenseItem {
id: number;
name: string;
amount: number;
selected: boolean;
}
interface ApiPriceAdjustmentItem {
id: number;
category: string;
unit_price: number;
coating: number;
batting: number;
box_reinforce: number;
painting: number;
total: number;
}
interface ApiDetailItem {
id: number;
no: number;
name: string;
material: string;
width: number;
height: number;
quantity: number;
box: number;
assembly: number;
coating: number;
batting: number;
mounting: number;
fitting: number;
controller: number;
width_construction: number;
height_construction: number;
material_cost: number;
labor_cost: number;
quantity_price: number;
expense_quantity: number;
expense_total: number;
total_cost: number;
other_cost: number;
margin_cost: number;
total_price: number;
unit_price: number;
expense: number;
margin_rate: number;
unit_quantity: number;
expense_result: number;
margin_actual: number;
}
// ========================================
// 타입 변환 함수
// ========================================
/**
* API 응답 → Estimate 타입 변환
* API 응답 (Quote) → Estimate 타입 변환
* 기존 프론트엔드 타입과 호환성 유지
*/
function transformEstimate(apiData: ApiEstimate): Estimate {
function transformQuoteToEstimate(apiData: ApiQuote): Estimate {
return {
id: String(apiData.id),
estimateCode: apiData.estimate_code || '',
partnerId: apiData.partner_id ? String(apiData.partner_id) : '',
partnerName: apiData.partner_name || '',
projectName: apiData.project_name || '',
estimatorId: apiData.estimator_id ? String(apiData.estimator_id) : '',
estimatorName: apiData.estimator_name || '',
itemCount: apiData.item_count || 0,
estimateAmount: apiData.estimate_amount || 0,
completedDate: apiData.completed_date || null,
bidDate: apiData.bid_date || null,
status: apiData.status || 'pending',
estimateCode: apiData.quote_number || '',
partnerId: apiData.client_id ? String(apiData.client_id) : '',
partnerName: apiData.client_name || '',
projectName: apiData.site_name || '',
estimatorId: apiData.created_by ? String(apiData.created_by) : '',
estimatorName: apiData.author || '',
itemCount: apiData.items?.length || 0,
estimateAmount: Number(apiData.total_amount) || 0,
completedDate: null,
bidDate: apiData.registration_date || null,
status: mapQuoteStatusToEstimateStatus(apiData.status),
createdAt: apiData.created_at || '',
updatedAt: apiData.updated_at || '',
createdBy: apiData.created_by || '',
createdBy: apiData.created_by ? String(apiData.created_by) : '',
};
}
/**
* Quote 상태 → Estimate 상태 매핑
*/
function mapQuoteStatusToEstimateStatus(
quoteStatus: string
): 'pending' | 'approval_waiting' | 'completed' | 'rejected' | 'hold' {
const statusMap: Record<string, 'pending' | 'approval_waiting' | 'completed' | 'rejected' | 'hold'> = {
pending: 'pending',
draft: 'pending',
sent: 'approval_waiting',
approved: 'completed',
rejected: 'rejected',
finalized: 'completed',
converted: 'completed',
};
return statusMap[quoteStatus] || 'pending';
}
/**
* API 응답 → EstimateDetail 타입 변환
*/
function transformEstimateDetail(apiData: ApiEstimateDetail): EstimateDetail {
const base = transformEstimate(apiData);
function transformQuoteToEstimateDetail(apiData: ApiQuote): EstimateDetail {
const base = transformQuoteToEstimate(apiData);
const siteBriefing: SiteBriefingInfo = apiData.site_briefing
? {
briefingCode: apiData.site_briefing.briefing_code || '',
partnerName: apiData.site_briefing.partner_name || '',
companyName: apiData.site_briefing.company_name || '',
partnerName: apiData.site_briefing.partner?.name || '',
companyName: '',
briefingDate: apiData.site_briefing.briefing_date || '',
attendee: apiData.site_briefing.attendee || '',
attendee: '',
}
: { briefingCode: '', partnerName: '', companyName: '', briefingDate: '', attendee: '' };
const bidInfo: BidInfo = apiData.bid_info
? {
projectName: apiData.bid_info.project_name || '',
bidDate: apiData.bid_info.bid_date || '',
siteCount: apiData.bid_info.site_count || 0,
constructionPeriod: apiData.bid_info.construction_period || '',
constructionStartDate: apiData.bid_info.construction_start_date || '',
constructionEndDate: apiData.bid_info.construction_end_date || '',
vatType: apiData.bid_info.vat_type || 'excluded',
workReport: apiData.bid_info.work_report || '',
documents: (apiData.bid_info.documents || []).map((d) => ({
id: String(d.id),
fileName: d.file_name || '',
fileUrl: d.file_url || '',
fileSize: d.file_size || 0,
})),
}
: {
projectName: '',
bidDate: '',
siteCount: 0,
constructionPeriod: '',
constructionStartDate: '',
constructionEndDate: '',
vatType: 'excluded',
workReport: '',
documents: [],
};
const bidInfo: BidInfo = {
projectName: apiData.site_name || '',
bidDate: apiData.registration_date || '',
siteCount: 0,
constructionPeriod: '',
constructionStartDate: '',
constructionEndDate: '',
vatType: 'excluded',
workReport: '',
documents: [],
};
const summaryItems: EstimateSummaryItem[] = (apiData.summary_items || []).map((item) => ({
id: String(item.id),
name: item.name || '',
quantity: item.quantity || 0,
unit: item.unit || '',
materialCost: item.material_cost || 0,
laborCost: item.labor_cost || 0,
totalCost: item.total_cost || 0,
remarks: item.remarks || '',
}));
const summaryItems: EstimateSummaryItem[] = [];
const expenseItems: ExpenseItem[] = [];
const priceAdjustments: PriceAdjustmentItem[] = [];
const expenseItems: ExpenseItem[] = (apiData.expense_items || []).map((item) => ({
const detailItems: EstimateDetailItem[] = (apiData.items || []).map((item, index) => ({
id: String(item.id),
name: item.name || '',
amount: item.amount || 0,
selected: item.selected || false,
}));
const priceAdjustments: PriceAdjustmentItem[] = (apiData.price_adjustments || []).map((item) => ({
id: String(item.id),
category: item.category || '',
unitPrice: item.unit_price || 0,
coating: item.coating || 0,
batting: item.batting || 0,
boxReinforce: item.box_reinforce || 0,
painting: item.painting || 0,
total: item.total || 0,
}));
const detailItems: EstimateDetailItem[] = (apiData.detail_items || []).map((item) => ({
id: String(item.id),
no: item.no || 0,
name: item.name || '',
material: item.material || '',
width: item.width || 0,
height: item.height || 0,
quantity: item.quantity || 0,
box: item.box || 0,
assembly: item.assembly || 0,
coating: item.coating || 0,
batting: item.batting || 0,
mounting: item.mounting || 0,
fitting: item.fitting || 0,
controller: item.controller || 0,
widthConstruction: item.width_construction || 0,
heightConstruction: item.height_construction || 0,
materialCost: item.material_cost || 0,
laborCost: item.labor_cost || 0,
quantityPrice: item.quantity_price || 0,
expenseQuantity: item.expense_quantity || 0,
expenseTotal: item.expense_total || 0,
totalCost: item.total_cost || 0,
otherCost: item.other_cost || 0,
marginCost: item.margin_cost || 0,
no: index + 1,
name: item.item_name || '',
material: item.specification || '',
width: 0,
height: 0,
quantity: item.calculated_quantity || 0,
box: 0,
assembly: 0,
coating: 0,
batting: 0,
mounting: 0,
fitting: 0,
controller: 0,
widthConstruction: 0,
heightConstruction: 0,
materialCost: 0,
laborCost: 0,
quantityPrice: item.unit_price || 0,
expenseQuantity: 0,
expenseTotal: 0,
totalCost: item.total_price || 0,
otherCost: 0,
marginCost: 0,
totalPrice: item.total_price || 0,
unitPrice: item.unit_price || 0,
expense: item.expense || 0,
marginRate: item.margin_rate || 0,
unitQuantity: item.unit_quantity || 0,
expenseResult: item.expense_result || 0,
marginActual: item.margin_actual || 0,
expense: 0,
marginRate: 0,
unitQuantity: item.base_quantity || 0,
expenseResult: 0,
marginActual: 0,
}));
return {
@@ -300,45 +240,37 @@ function transformEstimateDetail(apiData: ApiEstimateDetail): EstimateDetail {
function transformToApiRequest(data: Partial<EstimateDetailFormData>): Record<string, unknown> {
const apiData: Record<string, unknown> = {};
if (data.estimateCode !== undefined) apiData.estimate_code = data.estimateCode;
if (data.estimatorId !== undefined) apiData.estimator_id = data.estimatorId || null;
if (data.estimatorName !== undefined) apiData.estimator_name = data.estimatorName || null;
if (data.estimateAmount !== undefined) apiData.estimate_amount = data.estimateAmount;
if (data.status !== undefined) apiData.status = data.status;
if (data.siteBriefing !== undefined) {
apiData.site_briefing = {
briefing_code: data.siteBriefing.briefingCode,
partner_name: data.siteBriefing.partnerName,
company_name: data.siteBriefing.companyName,
briefing_date: data.siteBriefing.briefingDate,
attendee: data.siteBriefing.attendee,
if (data.estimateCode !== undefined) apiData.quote_number = data.estimateCode;
if (data.estimatorId !== undefined) apiData.created_by = data.estimatorId || null;
if (data.estimatorName !== undefined) apiData.author = data.estimatorName || null;
if (data.estimateAmount !== undefined) apiData.total_amount = data.estimateAmount;
if (data.status !== undefined) {
// Estimate 상태 → Quote 상태 역매핑
const reverseStatusMap: Record<string, string> = {
pending: 'pending',
approval_waiting: 'sent',
completed: 'finalized',
rejected: 'rejected',
hold: 'draft',
};
apiData.status = reverseStatusMap[data.status] || 'pending';
}
if (data.bidInfo !== undefined) {
apiData.bid_info = {
project_name: data.bidInfo.projectName,
bid_date: data.bidInfo.bidDate,
site_count: data.bidInfo.siteCount,
construction_period: data.bidInfo.constructionPeriod,
construction_start_date: data.bidInfo.constructionStartDate,
construction_end_date: data.bidInfo.constructionEndDate,
vat_type: data.bidInfo.vatType,
work_report: data.bidInfo.workReport,
};
apiData.site_name = data.bidInfo.projectName;
apiData.registration_date = data.bidInfo.bidDate;
}
return apiData;
}
// ========================================
// API 함수
// API 함수 (quotes API 사용)
// ========================================
/**
* 견적 목록 조회
* GET /api/v1/estimates
* 건설 견적 목록 조회
* GET /api/v1/quotes?quote_type=construction
*/
export async function getEstimateList(filter?: EstimateFilter): Promise<{
success: boolean;
@@ -346,62 +278,77 @@ export async function getEstimateList(filter?: EstimateFilter): Promise<{
error?: string;
}> {
try {
const queryParams: Record<string, string> = {};
const queryParams: Record<string, string> = {
quote_type: 'construction', // 건설 견적만 조회
};
// 검색
if (filter?.search) queryParams.search = filter.search;
if (filter?.search) queryParams.q = filter.search;
// 필터
if (filter?.status && filter.status !== 'all') queryParams.status = filter.status;
if (filter?.partnerId) queryParams.partner_id = filter.partnerId;
if (filter?.estimatorId) queryParams.estimator_id = filter.estimatorId;
if (filter?.status && filter.status !== 'all') {
// Estimate 상태 → Quote 상태로 변환
const statusMap: Record<string, string> = {
pending: 'pending',
approval_waiting: 'sent',
completed: 'finalized',
rejected: 'rejected',
hold: 'draft',
};
queryParams.status = statusMap[filter.status] || filter.status;
}
if (filter?.partnerId) queryParams.client_id = filter.partnerId;
// 날짜 범위
if (filter?.startDate) queryParams.start_date = filter.startDate;
if (filter?.endDate) queryParams.end_date = filter.endDate;
if (filter?.startDate) queryParams.date_from = filter.startDate;
if (filter?.endDate) queryParams.date_to = filter.endDate;
// 페이지네이션
if (filter?.page) queryParams.page = String(filter.page);
if (filter?.size) queryParams.per_page = String(filter.size);
if (filter?.size) queryParams.size = String(filter.size);
// 정렬
if (filter?.sortBy) {
const sortMap: Record<string, { field: string; dir: string }> = {
latest: { field: 'created_at', dir: 'desc' },
oldest: { field: 'created_at', dir: 'asc' },
amountDesc: { field: 'estimate_amount', dir: 'desc' },
amountAsc: { field: 'estimate_amount', dir: 'asc' },
bidDateDesc: { field: 'bid_date', dir: 'desc' },
partnerNameAsc: { field: 'partner_name', dir: 'asc' },
partnerNameDesc: { field: 'partner_name', dir: 'desc' },
projectNameAsc: { field: 'project_name', dir: 'asc' },
projectNameDesc: { field: 'project_name', dir: 'desc' },
amountDesc: { field: 'total_amount', dir: 'desc' },
amountAsc: { field: 'total_amount', dir: 'asc' },
bidDateDesc: { field: 'registration_date', dir: 'desc' },
partnerNameAsc: { field: 'client_name', dir: 'asc' },
partnerNameDesc: { field: 'client_name', dir: 'desc' },
projectNameAsc: { field: 'site_name', dir: 'asc' },
projectNameDesc: { field: 'site_name', dir: 'desc' },
};
const sort = sortMap[filter.sortBy];
if (sort) {
queryParams.sort_by = sort.field;
queryParams.sort_dir = sort.dir;
queryParams.sort_order = sort.dir;
}
}
const response = await apiClient.get<{
data: ApiEstimate[];
current_page: number;
per_page: number;
total: number;
last_page: number;
}>('/estimates', { params: queryParams });
success: boolean;
data: {
data: ApiQuote[];
current_page: number;
per_page: number;
total: number;
last_page: number;
};
}>('/quotes', { params: queryParams });
const items = (response.data || []).map(transformEstimate);
const paginatedData = response.data;
const items = (paginatedData.data || []).map(transformQuoteToEstimate);
return {
success: true,
data: {
items,
total: response.total || 0,
page: response.current_page || 1,
size: response.per_page || 20,
totalPages: response.last_page || 1,
total: paginatedData.total || 0,
page: paginatedData.current_page || 1,
size: paginatedData.per_page || 20,
totalPages: paginatedData.last_page || 1,
},
};
} catch (error) {
@@ -412,7 +359,7 @@ export async function getEstimateList(filter?: EstimateFilter): Promise<{
/**
* 견적 단건 조회
* GET /api/v1/estimates/{id}
* GET /api/v1/quotes/{id}
*/
export async function getEstimate(id: string): Promise<{
success: boolean;
@@ -420,8 +367,8 @@ export async function getEstimate(id: string): Promise<{
error?: string;
}> {
try {
const response = await apiClient.get<ApiEstimate>(`/estimates/${id}`);
return { success: true, data: transformEstimate(response) };
const response = await apiClient.get<{ success: boolean; data: ApiQuote }>(`/quotes/${id}`);
return { success: true, data: transformQuoteToEstimate(response.data) };
} catch (error) {
console.error('견적 조회 오류:', error);
return { success: false, error: '견적 정보를 찾을 수 없습니다.' };
@@ -430,7 +377,7 @@ export async function getEstimate(id: string): Promise<{
/**
* 견적 상세 조회 (첨부 정보 포함)
* GET /api/v1/estimates/{id}/detail
* GET /api/v1/quotes/{id}
*/
export async function getEstimateDetail(id: string): Promise<{
success: boolean;
@@ -438,8 +385,8 @@ export async function getEstimateDetail(id: string): Promise<{
error?: string;
}> {
try {
const response = await apiClient.get<ApiEstimateDetail>(`/estimates/${id}`);
return { success: true, data: transformEstimateDetail(response) };
const response = await apiClient.get<{ success: boolean; data: ApiQuote }>(`/quotes/${id}`);
return { success: true, data: transformQuoteToEstimateDetail(response.data) };
} catch (error) {
console.error('견적 상세 조회 오류:', error);
return { success: false, error: '견적 상세 정보를 불러오는데 실패했습니다.' };
@@ -448,7 +395,8 @@ export async function getEstimateDetail(id: string): Promise<{
/**
* 견적 통계 조회
* GET /api/v1/estimates/stats
* GET /api/v1/quotes/stats (건설용)
* 현재는 목록 조회로 대체
*/
export async function getEstimateStats(): Promise<{
success: boolean;
@@ -456,14 +404,25 @@ export async function getEstimateStats(): Promise<{
error?: string;
}> {
try {
const response = await apiClient.get<ApiEstimateStats>('/estimates/stats');
// 통계 API가 없으므로 목록 조회로 대체
const [allResponse, pendingResponse] = await Promise.all([
apiClient.get<{ success: boolean; data: { total: number } }>('/quotes', {
params: { quote_type: 'construction', size: '1' },
}),
apiClient.get<{ success: boolean; data: { total: number } }>('/quotes', {
params: { quote_type: 'construction', status: 'pending', size: '1' },
}),
]);
const total = allResponse.data?.total || 0;
const pending = pendingResponse.data?.total || 0;
return {
success: true,
data: {
total: response.total_count || 0,
pending: response.pending_count || 0,
completed: response.completed_count || 0,
total,
pending,
completed: total - pending,
},
};
} catch (error) {
@@ -474,7 +433,7 @@ export async function getEstimateStats(): Promise<{
/**
* 견적 등록
* POST /api/v1/estimates
* POST /api/v1/quotes
*/
export async function createEstimate(data: EstimateDetailFormData): Promise<{
success: boolean;
@@ -482,9 +441,12 @@ export async function createEstimate(data: EstimateDetailFormData): Promise<{
error?: string;
}> {
try {
const apiData = transformToApiRequest(data);
const response = await apiClient.post<ApiEstimate>('/estimates', apiData);
return { success: true, data: transformEstimate(response) };
const apiData = {
...transformToApiRequest(data),
quote_type: 'construction', // 건설 견적으로 생성
};
const response = await apiClient.post<ApiQuote>('/quotes', apiData);
return { success: true, data: transformQuoteToEstimate(response) };
} catch (error) {
console.error('견적 등록 오류:', error);
return { success: false, error: '견적 등록에 실패했습니다.' };
@@ -493,7 +455,7 @@ export async function createEstimate(data: EstimateDetailFormData): Promise<{
/**
* 견적 수정
* PUT /api/v1/estimates/{id}
* PUT /api/v1/quotes/{id}
*/
export async function updateEstimate(
id: string,
@@ -505,8 +467,8 @@ export async function updateEstimate(
}> {
try {
const apiData = transformToApiRequest(data);
const response = await apiClient.put<ApiEstimate>(`/estimates/${id}`, apiData);
return { success: true, data: transformEstimate(response) };
const response = await apiClient.put<ApiQuote>(`/quotes/${id}`, apiData);
return { success: true, data: transformQuoteToEstimate(response) };
} catch (error) {
console.error('견적 수정 오류:', error);
return { success: false, error: '견적 수정에 실패했습니다.' };
@@ -515,14 +477,14 @@ export async function updateEstimate(
/**
* 견적 삭제
* DELETE /api/v1/estimates/{id}
* DELETE /api/v1/quotes/{id}
*/
export async function deleteEstimate(id: string): Promise<{
success: boolean;
error?: string;
}> {
try {
await apiClient.delete(`/estimates/${id}`);
await apiClient.delete(`/quotes/${id}`);
return { success: true };
} catch (error) {
console.error('견적 삭제 오류:', error);
@@ -532,7 +494,7 @@ export async function deleteEstimate(id: string): Promise<{
/**
* 견적 일괄 삭제
* DELETE /api/v1/estimates/bulk
* DELETE /api/v1/quotes/bulk
*/
export async function deleteEstimates(ids: string[]): Promise<{
success: boolean;
@@ -540,7 +502,7 @@ export async function deleteEstimates(ids: string[]): Promise<{
error?: string;
}> {
try {
await apiClient.delete('/estimates/bulk', {
await apiClient.delete('/quotes/bulk', {
data: { ids: ids.map((id) => Number(id)) },
});
return { success: true, deletedCount: ids.length };